统一桌面壳宿主上下文补写
收敛 Tauri 配置为基础 H5 入口 由 Rust shell/url 统一补写桌面宿主上下文 增加桌面 HostBridge 协议常量漂移检查 增加微信壳 capability 共享白名单校验 更新原生壳方案和共享决策记录
This commit is contained in:
@@ -676,6 +676,30 @@ function extractRustStringArrayConst(source, constName) {
|
||||
return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]);
|
||||
}
|
||||
|
||||
function extractRustStringConst(source, constName) {
|
||||
const match = source.match(
|
||||
new RegExp(`(?:pub\\(crate\\)\\s+)?const ${constName}\\s*:\\s*&str\\s*=\\s*"([^"]+)";`),
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error(`unable to read Rust const ${constName}`);
|
||||
}
|
||||
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function extractRustNumberConst(source, constName) {
|
||||
const match = source.match(
|
||||
new RegExp(
|
||||
`(?:pub\\(crate\\)\\s+)?const ${constName}\\s*:\\s*(?:u8|u16|u32|usize|i64|i32)\\s*=\\s*(\\d+);`,
|
||||
),
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error(`unable to read Rust const ${constName}`);
|
||||
}
|
||||
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
function extractTsStringConst(source, constName) {
|
||||
const match = source.match(
|
||||
new RegExp(`export const ${constName}\\s*=\\s*'([^']+)';`),
|
||||
@@ -687,6 +711,17 @@ function extractTsStringConst(source, constName) {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function extractTsNumberConst(source, constName) {
|
||||
const match = source.match(
|
||||
new RegExp(`export const ${constName}\\s*=\\s*(\\d+);`),
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error(`unable to read TypeScript const ${constName}`);
|
||||
}
|
||||
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
function extractDesktopCapabilities(source) {
|
||||
const match = source.match(/fn capabilities\(\)[^{]*\{[\s\S]*?vec!\[([\s\S]*?)\]\s*\}/);
|
||||
if (!match) {
|
||||
@@ -713,17 +748,25 @@ function extractDesktopHandledMethods(source) {
|
||||
];
|
||||
}
|
||||
|
||||
function resolveHostCapabilitiesFromUrl(rawUrl) {
|
||||
const url = new URL(rawUrl, 'https://app.genarrative.world/');
|
||||
return (url.searchParams.get('hostCapabilities') ?? '')
|
||||
.split(',')
|
||||
.map((capability) => capability.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
function assertNoRawHostContextUrl(urlValue, label) {
|
||||
const rawUrl = String(urlValue ?? '');
|
||||
const blockedQueryKeys = [
|
||||
'clientRuntime',
|
||||
'clientType',
|
||||
'hostShell',
|
||||
'hostPlatform',
|
||||
'hostVersion',
|
||||
'bridgeVersion',
|
||||
'hostCapabilities',
|
||||
];
|
||||
|
||||
function resolveHostVersionFromUrl(rawUrl) {
|
||||
const url = new URL(rawUrl, 'https://app.genarrative.world/');
|
||||
return url.searchParams.get('hostVersion');
|
||||
for (const key of blockedQueryKeys) {
|
||||
if (rawUrl.includes(`${key}=`)) {
|
||||
throw new Error(
|
||||
`${label} must not hardcode ${key}; desktop Rust shell/url.rs must append host context`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertSameList(actual, expected, label) {
|
||||
@@ -840,7 +883,23 @@ const sharedMethods = extractStringArrayExport(
|
||||
sharedContractSource,
|
||||
'HOST_BRIDGE_METHODS',
|
||||
);
|
||||
const sharedHostBridgeProtocol = extractTsStringConst(
|
||||
sharedContractSource,
|
||||
'HOST_BRIDGE_PROTOCOL',
|
||||
);
|
||||
const sharedHostBridgeVersion = extractTsNumberConst(
|
||||
sharedContractSource,
|
||||
'HOST_BRIDGE_VERSION',
|
||||
);
|
||||
const desktopMethods = extractRustStringArrayConst(rustHostSource, 'HOST_BRIDGE_METHODS');
|
||||
const desktopHostBridgeProtocol = extractRustStringConst(
|
||||
rustHostSource,
|
||||
'HOST_BRIDGE_PROTOCOL',
|
||||
);
|
||||
const desktopHostBridgeVersion = extractRustNumberConst(
|
||||
rustHostSource,
|
||||
'HOST_BRIDGE_VERSION',
|
||||
);
|
||||
const desktopCapabilities = extractDesktopCapabilities(
|
||||
desktopHostBridgeCapabilitiesSource,
|
||||
);
|
||||
@@ -848,6 +907,18 @@ const desktopHandledMethods = extractDesktopHandledMethods(
|
||||
desktopHostBridgeDispatchSource,
|
||||
);
|
||||
const sdkBackedCapabilities = ['auth.requestLogin', 'payment.request'];
|
||||
if (desktopHostBridgeProtocol !== sharedHostBridgeProtocol) {
|
||||
throw new Error(
|
||||
`desktop shell HostBridge protocol drifted: expected ${sharedHostBridgeProtocol} but got ${desktopHostBridgeProtocol}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (desktopHostBridgeVersion !== sharedHostBridgeVersion) {
|
||||
throw new Error(
|
||||
`desktop shell HostBridge version drifted: expected ${sharedHostBridgeVersion} but got ${desktopHostBridgeVersion}`,
|
||||
);
|
||||
}
|
||||
|
||||
assertSameList(desktopMethods, sharedMethods, 'desktop shell HostBridge method whitelist');
|
||||
const unknownHandledDesktopMethods = desktopHandledMethods.filter(
|
||||
(method) => !sharedMethods.includes(method),
|
||||
@@ -953,14 +1024,17 @@ if (String(mainWindow.url ?? '').startsWith('http')) {
|
||||
throw new Error('desktop shell release window must load packaged H5 assets');
|
||||
}
|
||||
|
||||
if (!String(mainWindow.url ?? '').startsWith('index.html?')) {
|
||||
if (String(mainWindow.url ?? '') !== 'index.html') {
|
||||
throw new Error('desktop shell release window must enter through packaged index.html');
|
||||
}
|
||||
|
||||
if (!String(config.build?.devUrl ?? '').startsWith('http://127.0.0.1:3000/?')) {
|
||||
if (String(config.build?.devUrl ?? '') !== 'http://127.0.0.1:3000/') {
|
||||
throw new Error('desktop shell dev URL must load the local Vite H5 entry');
|
||||
}
|
||||
|
||||
assertNoRawHostContextUrl(mainWindow.url, 'desktop shell main window URL');
|
||||
assertNoRawHostContextUrl(config.build?.devUrl, 'desktop shell dev URL');
|
||||
|
||||
if (mainWindow.devtools !== false) {
|
||||
throw new Error('desktop shell main WebView devtools must be explicitly disabled');
|
||||
}
|
||||
@@ -1015,53 +1089,6 @@ for (const requiredDevCspToken of [
|
||||
}
|
||||
}
|
||||
|
||||
const requiredUrlParts = [
|
||||
'clientRuntime=native_app',
|
||||
'clientType=native_app',
|
||||
'hostShell=tauri_desktop',
|
||||
'hostPlatform=unknown',
|
||||
'bridgeVersion=1',
|
||||
];
|
||||
|
||||
for (const part of requiredUrlParts) {
|
||||
if (!String(mainWindow.url ?? '').includes(part)) {
|
||||
throw new Error(`desktop shell main window URL missing ${part}`);
|
||||
}
|
||||
if (!String(config.build?.devUrl ?? '').includes(part)) {
|
||||
throw new Error(`desktop shell dev URL missing ${part}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolveHostVersionFromUrl(mainWindow.url) !== config.version) {
|
||||
throw new Error('desktop shell main window hostVersion must match tauri.conf.json version');
|
||||
}
|
||||
|
||||
if (resolveHostVersionFromUrl(config.build?.devUrl ?? '') !== config.version) {
|
||||
throw new Error('desktop shell dev hostVersion must match tauri.conf.json version');
|
||||
}
|
||||
|
||||
assertSameList(
|
||||
resolveHostCapabilitiesFromUrl(mainWindow.url),
|
||||
desktopCapabilities,
|
||||
'desktop shell main window hostCapabilities',
|
||||
);
|
||||
assertSameList(
|
||||
resolveHostCapabilitiesFromUrl(config.build?.devUrl ?? ''),
|
||||
desktopCapabilities,
|
||||
'desktop shell dev hostCapabilities',
|
||||
);
|
||||
|
||||
for (const capability of sdkBackedCapabilities) {
|
||||
if (
|
||||
resolveHostCapabilitiesFromUrl(mainWindow.url).includes(capability) ||
|
||||
resolveHostCapabilitiesFromUrl(config.build?.devUrl ?? '').includes(capability)
|
||||
) {
|
||||
throw new Error(
|
||||
`desktop shell URL must not advertise ${capability} without a real SDK/channel flow`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const allowedPermissions = [
|
||||
'allow-host-bridge-request',
|
||||
];
|
||||
@@ -1251,7 +1278,7 @@ const requiredRustHostSnippets = [
|
||||
'__genarrativeDesktopHistoryIndex',
|
||||
'file.imageDropped',
|
||||
'app.notification().builder()',
|
||||
'desktop_entry_url_with_platform',
|
||||
'desktop_entry_url_with_host_context',
|
||||
'desktop_h5_url_with_host_context',
|
||||
'desktop_h5_url_with_host_context(target_url)',
|
||||
'desktop_h5_url_with_host_context(normalized_url)',
|
||||
|
||||
@@ -49,23 +49,33 @@ pub(crate) fn desktop_h5_url_with_host_context(mut target_url: Url) -> Option<Ur
|
||||
Some(target_url)
|
||||
}
|
||||
|
||||
pub(crate) fn desktop_entry_url_with_platform(raw_url: &str) -> String {
|
||||
let platform = desktop_platform();
|
||||
if let Ok(mut url) = Url::parse(raw_url) {
|
||||
let query_pairs = url
|
||||
.query_pairs()
|
||||
.filter(|(key, _)| key != "hostPlatform")
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect::<Vec<_>>();
|
||||
url.query_pairs_mut()
|
||||
.clear()
|
||||
.extend_pairs(
|
||||
query_pairs
|
||||
.iter()
|
||||
.map(|(key, value)| (key.as_str(), value.as_str())),
|
||||
)
|
||||
.append_pair("hostPlatform", platform);
|
||||
return url.to_string();
|
||||
fn append_desktop_host_context_to_url(mut url: Url) -> String {
|
||||
let retained_query_pairs = url
|
||||
.query_pairs()
|
||||
.filter(|(key, _)| !HOST_CONTEXT_QUERY_KEYS.contains(&key.as_ref()))
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect::<Vec<_>>();
|
||||
url.query_pairs_mut()
|
||||
.clear()
|
||||
.extend_pairs(
|
||||
retained_query_pairs
|
||||
.iter()
|
||||
.map(|(key, value)| (key.as_str(), value.as_str())),
|
||||
)
|
||||
.append_pair("clientRuntime", "native_app")
|
||||
.append_pair("clientType", "native_app")
|
||||
.append_pair("hostShell", "tauri_desktop")
|
||||
.append_pair("hostPlatform", desktop_platform())
|
||||
.append_pair("hostVersion", env!("CARGO_PKG_VERSION"))
|
||||
.append_pair("bridgeVersion", &HOST_BRIDGE_VERSION.to_string())
|
||||
.append_pair("hostCapabilities", &capabilities().join(","));
|
||||
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn desktop_entry_url_with_host_context(raw_url: &str) -> String {
|
||||
if let Ok(url) = Url::parse(raw_url) {
|
||||
return append_desktop_host_context_to_url(url);
|
||||
}
|
||||
|
||||
let (without_hash, hash) = raw_url
|
||||
@@ -80,17 +90,25 @@ pub(crate) fn desktop_entry_url_with_platform(raw_url: &str) -> String {
|
||||
query
|
||||
.split('&')
|
||||
.filter(|pair| {
|
||||
!pair.is_empty()
|
||||
&& !pair
|
||||
.split_once('=')
|
||||
.map(|(key, _)| key == "hostPlatform")
|
||||
.unwrap_or(false)
|
||||
if pair.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let key = pair.split_once('=').map(|(key, _)| key).unwrap_or(pair);
|
||||
!HOST_CONTEXT_QUERY_KEYS.contains(&key)
|
||||
})
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
pairs.push(format!("hostPlatform={platform}"));
|
||||
pairs.extend([
|
||||
"clientRuntime=native_app".to_string(),
|
||||
"clientType=native_app".to_string(),
|
||||
"hostShell=tauri_desktop".to_string(),
|
||||
format!("hostPlatform={}", desktop_platform()),
|
||||
format!("hostVersion={}", env!("CARGO_PKG_VERSION")),
|
||||
format!("bridgeVersion={HOST_BRIDGE_VERSION}"),
|
||||
format!("hostCapabilities={}", capabilities().join(",")),
|
||||
]);
|
||||
let normalized_url = format!("{path}?{}", pairs.join("&"));
|
||||
if let Some(hash) = hash {
|
||||
format!("{normalized_url}#{hash}")
|
||||
@@ -104,12 +122,12 @@ pub(crate) fn desktop_window_config_with_runtime_platform(
|
||||
) -> tauri::utils::config::WindowConfig {
|
||||
config.url = match config.url {
|
||||
WebviewUrl::External(url) => WebviewUrl::External(
|
||||
Url::parse(&desktop_entry_url_with_platform(url.as_str())).unwrap_or(url),
|
||||
Url::parse(&desktop_entry_url_with_host_context(url.as_str())).unwrap_or(url),
|
||||
),
|
||||
WebviewUrl::CustomProtocol(url) => WebviewUrl::CustomProtocol(
|
||||
Url::parse(&desktop_entry_url_with_platform(url.as_str())).unwrap_or(url),
|
||||
Url::parse(&desktop_entry_url_with_host_context(url.as_str())).unwrap_or(url),
|
||||
),
|
||||
WebviewUrl::App(path) => WebviewUrl::App(PathBuf::from(desktop_entry_url_with_platform(
|
||||
WebviewUrl::App(path) => WebviewUrl::App(PathBuf::from(desktop_entry_url_with_host_context(
|
||||
path.to_string_lossy().as_ref(),
|
||||
))),
|
||||
other => other,
|
||||
@@ -181,14 +199,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_entry_url_replaces_static_platform_marker() {
|
||||
fn desktop_entry_url_adds_host_context_from_plain_entries() {
|
||||
let platform = desktop_platform();
|
||||
|
||||
let dev_url = desktop_entry_url_with_platform(
|
||||
"http://127.0.0.1:3000/?clientRuntime=native_app&hostPlatform=unknown&bridgeVersion=1",
|
||||
);
|
||||
let dev_url = desktop_entry_url_with_host_context("http://127.0.0.1:3000/");
|
||||
let dev_url = Url::parse(&dev_url).expect("dev url");
|
||||
|
||||
assert_eq!(
|
||||
dev_url
|
||||
.query_pairs()
|
||||
.find(|(key, _)| key == "clientRuntime"),
|
||||
Some(("clientRuntime".into(), "native_app".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
dev_url.query_pairs().find(|(key, _)| key == "hostPlatform"),
|
||||
Some(("hostPlatform".into(), platform.into()))
|
||||
@@ -200,13 +222,28 @@ mod tests {
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
|
||||
let packaged_url = desktop_entry_url_with_platform(
|
||||
"index.html?clientRuntime=native_app&hostPlatform=unknown&bridgeVersion=1#works",
|
||||
assert_eq!(
|
||||
dev_url.query_pairs().find(|(key, _)| key == "hostVersion"),
|
||||
Some(("hostVersion".into(), env!("CARGO_PKG_VERSION").into()))
|
||||
);
|
||||
assert_eq!(
|
||||
dev_url.query_pairs().find(|(key, _)| key == "bridgeVersion"),
|
||||
Some(("bridgeVersion".into(), HOST_BRIDGE_VERSION.to_string().into()))
|
||||
);
|
||||
assert_eq!(
|
||||
dev_url.query_pairs().find(|(key, _)| key == "hostCapabilities"),
|
||||
Some(("hostCapabilities".into(), capabilities().join(",").into()))
|
||||
);
|
||||
|
||||
let packaged_url = desktop_entry_url_with_host_context("index.html#works");
|
||||
|
||||
assert!(packaged_url.contains("clientRuntime=native_app"));
|
||||
assert!(packaged_url.contains("clientType=native_app"));
|
||||
assert!(packaged_url.contains("hostShell=tauri_desktop"));
|
||||
assert!(packaged_url.contains(&format!("hostPlatform={platform}")));
|
||||
assert!(!packaged_url.contains("hostPlatform=unknown"));
|
||||
assert!(packaged_url.contains(&format!("hostVersion={}", env!("CARGO_PKG_VERSION"))));
|
||||
assert!(packaged_url.contains(&format!("bridgeVersion={HOST_BRIDGE_VERSION}")));
|
||||
assert!(packaged_url.contains(&format!("hostCapabilities={}", capabilities().join(","))));
|
||||
assert!(packaged_url.ends_with("#works"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"build": {
|
||||
"beforeDevCommand": "npm --prefix ../.. run dev:web",
|
||||
"beforeBuildCommand": "npm --prefix ../.. run build:raw && npm run typecheck",
|
||||
"devUrl": "http://127.0.0.1:3000/?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,host.events,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,navigation.canGoBack,app.reloadWebView,app.openExternalUrl,app.setTitle,app.setBadgeCount,network.status,network.statusChanged,clipboard.writeText,clipboard.readText,file.exportText,file.importText,file.exportImage,file.importImage,file.importAudio,file.exportAudio,file.imageDropped,notification.showLocal",
|
||||
"devUrl": "http://127.0.0.1:3000/",
|
||||
"frontendDist": "../../../dist"
|
||||
},
|
||||
"app": {
|
||||
@@ -14,7 +14,7 @@
|
||||
{
|
||||
"create": false,
|
||||
"label": "main",
|
||||
"url": "index.html?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,host.events,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,navigation.canGoBack,app.reloadWebView,app.openExternalUrl,app.setTitle,app.setBadgeCount,network.status,network.statusChanged,clipboard.writeText,clipboard.readText,file.exportText,file.importText,file.exportImage,file.importImage,file.importAudio,file.exportAudio,file.imageDropped,notification.showLocal",
|
||||
"url": "index.html",
|
||||
"title": "Genarrative",
|
||||
"width": 1280,
|
||||
"height": 820,
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
- 2026-06-18 移动壳 WebView 导航收紧:Expo WebView 自身拦截外域导航时复用 HostBridge 外链协议白名单,只把 `http:`、`https:`、`mailto:`、`tel:` 交给 `Linking.openURL`,`javascript:`、`file:`、相对异常路径等危险目标直接阻断,避免离开同源主站后仍保留完整 HostBridge。
|
||||
- 2026-06-18 能力声明收紧:`packages/shared/src/contracts/hostBridge.ts` 提供 HostBridge method / capability 白名单,H5 的 `getHostRuntime()` 会解析并过滤 `hostCapabilities`;`openHostShare`、`writeHostClipboardText`、`requestHostHapticsImpact`、`setHostAppTitle`、`exportHostTextFile` 等 native 能力只在宿主声明对应 capability 后调用。发布分享弹窗只有声明 `share.open` 时才显示“系统分享”,避免旧壳或裁剪壳露出不可用入口。
|
||||
- 2026-06-18 宿主 runtime 回读:主 App 启动时会通过真实 `host.getRuntime` 回读 Expo / Tauri runtime 并缓存过滤后的能力清单,能力来源为 URL `hostCapabilities` 与宿主真实回包的并集;裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时也能启用真实声明能力,但仍不会仅凭 `native_app` 或 transport 存在推断能力可用。
|
||||
- 2026-06-18 壳能力防漂移:`npm run mobile-shell:typecheck` 与 `npm run desktop-shell:typecheck` 会校验 Expo / Tauri 壳声明的 capability 均来自共享 HostBridge 白名单,并校验壳 runtime 回包、H5 URL `hostCapabilities` 和实现分支保持一致;新增能力必须先更新契约和真实壳实现,再通过这些检查。
|
||||
- 2026-06-18 壳能力防漂移:`npm run mobile-shell:typecheck` 与 `npm run desktop-shell:typecheck` 会校验 Expo / Tauri 壳声明的 capability 均来自共享 HostBridge 白名单,并校验壳 runtime 回包、H5 URL `hostCapabilities` 和实现分支保持一致;微信小程序 `WECHAT_HOST_CAPABILITIES` 也由 `miniprogram/host-bridge/protocol.test.js` 反查同一共享白名单。新增能力必须先更新契约和真实壳实现,再通过这些检查。
|
||||
- 2026-06-18 原生壳统一验收门禁:根级 `npm run check:native-shells` 统一执行 H5 HostBridge 关键测试、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测和微信 / Expo / Tauri 三端生产壳临时替身词扫描;根级 `npm run check` 会在 lint、主站测试、构建和内容检查后继续执行该门禁,避免 HostBridge、三端壳、Expo managed config、移动端 production bundle、桌面 release 入口和壳生产源码禁替身验收散落成容易漏跑的单项命令。
|
||||
- 2026-06-18 微信壳桥接层纳入统一验收:`npm run check:native-shells` 还会运行 `miniprogram/host-bridge/`、`miniprogram/shell/`、`pages/web-view` 样式和 `scripts/miniprogram-web-view-auth.test.ts` 的微信壳测试,覆盖 WebView 入口、登录触发、分享目标、支付结果、订阅消息结果和九宫切图行为;三端桥接层文件结构检查只证明目录边界,行为回归必须由同一门禁中的微信壳测试证明。
|
||||
- 2026-06-18 登录 / 支付能力禁伪声明:`auth.requestLogin` 和 `payment.request` 保留在共享 HostBridge 契约中供未来真实接入,但 Expo / Tauri 壳在真实 SDK、渠道流程和后端契约落地前不得声明这些 capability,也不得把它们写入入口 URL `hostCapabilities`;两端检查脚本会拒绝伪声明,请求实际到达壳层时必须返回明确 `unsupported_method` 并让 H5 fallback,两端壳测试直接覆盖这两个 method。
|
||||
@@ -53,6 +53,7 @@
|
||||
- 2026-06-18 移动壳启动 URL 归一:Expo 壳的 `EXPO_PUBLIC_GENARRATIVE_WEB_URL` 和 deep link 基准地址只接受生产主站 `https://app.genarrative.world`,以及本机开发联调 `http://127.0.0.1`、`http://localhost`、`http://[::1]`;空值、相对路径、外域、`file:`、`javascript:` 等非法配置回退到默认 H5 地址后再附加 `native_app` 宿主上下文;deep link 仍只映射归一后基准 origin 的 H5 路径,禁止把外域或危险协议页面装进带完整 HostBridge 的 WebView。
|
||||
- 2026-06-18 移动壳主动导航上下文:Expo 壳的 `navigation.openNativePage` 与 deep link 都必须复用 `buildMobileShellUrl(...)` 补写 `native_app`、`expo_mobile`、真实平台、版本和 capability 清单;受控导航只接受当前允许 origin 的同源 H5 URL。移动壳配置检查会拒绝主动导航或 deep link 绕过该宿主上下文构造入口。
|
||||
- 2026-06-18 移动壳协议常量来源:Expo 壳的 HostBridge 事件注入、入口 URL `bridgeVersion`、`host.getRuntime` 回包和 Expo public config smoke 必须使用 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_PROTOCOL` / `HOST_BRIDGE_VERSION`,不得在壳层重新写死协议名或版本字面量;配置检查会拒绝这些常量漂移。
|
||||
- 2026-06-18 桌面壳协议常量来源:Tauri Rust 侧 `host_bridge/protocol.rs` 的 `HOST_BRIDGE_PROTOCOL` / `HOST_BRIDGE_VERSION`、桌面入口 URL `bridgeVersion`、HostBridge event 注入和 runtime 回包必须与 `packages/shared/src/contracts/hostBridge.ts` 保持一致;桌面配置检查会反查共享契约并拒绝协议名或协议版本漂移。`tauri.conf.json` 只保留基础入口,`shell/url.rs` 统一补写桌面宿主上下文和真实 capability 清单,配置检查会拒绝把 `hostCapabilities` 等宿主 query 长串重新写回 Tauri 配置。
|
||||
- 2026-06-18 移动壳默认入口:Expo 壳默认 H5 地址固定为 `https://app.genarrative.world/`,开发联调本机 Vite 必须显式设置 `EXPO_PUBLIC_GENARRATIVE_WEB_URL=http://127.0.0.1:3000/`、`http://localhost:3000/` 或 `http://[::1]:3000/`;生产包不得在未配置环境变量时加载设备本机 localhost,也不得通过环境变量把第三方外域 H5 放入带完整 HostBridge 的 WebView。
|
||||
- 2026-06-18 移动壳安装包身份:Expo 移动壳的 iOS bundle identifier 与 Android package 统一固定为 `world.genarrative.mobile`,应用版本固定为 `0.1.0`,iOS `buildNumber` 从字符串 `"1"` 起步,Android `versionCode` 从整数 `1` 起步;后续分发安装包时递增构建号 / versionCode,产品版本号按发布节奏调整。移动壳配置检查会校验 `app.json` 与 `package.json` 版本一致,并拒绝缺失或漂移的包标识,当前不写入假商店元数据、假更新端点或占位渠道 SDK 配置。
|
||||
- 2026-06-18 移动壳 HostBridge 版本单一来源:Expo 移动壳的 H5 入口 query 和 `host.getRuntime` 回包都读取 `MOBILE_SHELL_HOST_VERSION`,该常量必须与 `app.json` / `package.json` 版本一致;配置检查会拒绝 `App.tsx` 或 `bridge.ts` 重新散落硬编码版本,避免安装包版本升级时 H5 首屏上下文与 runtime 回读分叉。
|
||||
@@ -71,8 +72,8 @@
|
||||
- 2026-06-18 Tauri 桌面深链:桌面壳启用 `tauri-plugin-deep-link`,但不安装 JS guest 包、不把 deep-link command 加入主窗口 capability,也不新增 HostBridge capability。Tauri 配置只注册 `genarrative` scheme;Rust 层只接受 `genarrative://open/...`、`genarrative://app/...`、`genarrative://<path>` 和 `https://app.genarrative.world/...`,统一跳转到同源 H5 并补写 `native_app`、`tauri_desktop`、当前平台、版本和真实 capability 清单;外域、明文协议和危险协议不进入主 WebView。
|
||||
- 2026-06-18 桌面壳安装包身份:Tauri 桌面壳的产品名固定为 `Genarrative`,应用 identifier 固定为 `world.genarrative.desktop`,Tauri 配置、`apps/desktop-shell/package.json` 与 Cargo package 版本统一为 `0.1.0`;Release 主窗口只加载打包的 `index.html` 和根 `dist` H5 资产,dev URL 只指向本机 Vite 调试入口。桌面壳 CSP 保持 `script-src 'self'`,不得加入 `unsafe-eval`、`tauri:` 或 `file:`,也不得在没有真实端点、签名密钥和发布流程前配置 updater;检查脚本会拒绝包身份、版本、CSP 或 updater 约束漂移。
|
||||
- 2026-06-18 桌面壳观测与渠道 SDK 边界:Tauri 桌面壳默认不接入崩溃上报、analytics、遥测日志、自动更新或渠道分发 SDK;Sentry、Datadog、PostHog、Segment、Amplitude、Bugsnag、OpenTelemetry、Tauri log / updater 等 Node / Cargo 依赖、`package-lock.json` / `Cargo.lock` 解析包和 Rust 初始化片段都会被配置检查拒绝。后续只有在真实端点、采集字段、用户授权、隐私披露、签名和发布流程确定后,才能按单项能力更新方案并接入。
|
||||
- 2026-06-18 桌面壳 HostBridge 版本边界:Tauri release / dev 入口 URL 的 `hostVersion` 必须与 `tauri.conf.json`、`apps/desktop-shell/package.json` 和 Cargo package 版本一致;`host.getRuntime` 回包继续使用 `env!("CARGO_PKG_VERSION")`,配置检查会拒绝入口 query 版本、Tauri 配置版本或 Rust runtime 版本来源分叉。
|
||||
- 2026-06-18 桌面壳运行时平台 query:Tauri 静态配置中的 `hostPlatform=unknown` 只作为跨平台构建模板值;Rust `setup` 手动创建主窗口前必须把入口 URL 改写为当前 `macos` / `windows` / `linux`,保证 H5 首屏 query 与 `host.getRuntime` 回读的平台一致。第二实例参数、外部 deep link 或 H5 自报值不得覆盖该字段;桌面壳测试和配置检查会拒绝绕过该归一流程。
|
||||
- 2026-06-18 桌面壳 HostBridge 版本边界:Tauri release / dev 入口 URL 的 `hostVersion` 由 Rust `shell/url.rs` 从 Cargo package 版本统一补写;`host.getRuntime` 回包继续使用 `env!("CARGO_PKG_VERSION")`,配置检查会确认 `tauri.conf.json`、`apps/desktop-shell/package.json` 和 Cargo package 版本一致,并拒绝在 Tauri 配置里手写入口 query 版本。
|
||||
- 2026-06-18 桌面壳运行时平台 query:Tauri 静态配置不再写入 `hostPlatform` 或其它宿主上下文 query;Rust `setup` 手动创建主窗口前必须把基础入口改写为当前 `macos` / `windows` / `linux` 平台和完整宿主上下文,保证 H5 首屏 query 与 `host.getRuntime` 回读的平台一致。第二实例参数、外部 deep link 或 H5 自报值不得覆盖该字段;桌面壳测试和配置检查会拒绝绕过该归一流程。
|
||||
- 2026-06-18 桌面壳顶层导航边界:Tauri 主 WebView 只允许打包资产 URL 和 `https://app.genarrative.world` 同源 H5 route 留在主窗口;外域 `http:` / `https:`、`mailto:`、`tel:` 导航与 `window.open` 请求交给系统 opener 后拒绝 WebView 留壳;`javascript:`、`file:` 等危险协议直接拒绝。该规则不进入 HostBridge capability,不开放 opener JS guest API,配置检查和 cargo test 覆盖导航策略。
|
||||
- 2026-06-18 桌面壳默认下载边界:Tauri 主 WebView 的下载事件默认拒绝网页自动下载和 `<a download>` 落盘,桌面文件保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等已声明 HostBridge method 进入 Rust 侧系统保存对话框,并继续执行 MIME、大小、文件名清洗和用户确认。该规则不进入 HostBridge capability,配置检查和 cargo test 覆盖下载拒绝策略。
|
||||
- 2026-06-18 桌面壳文件 bytes 校验:Tauri 图片 / 音频导入导出不得只信扩展名或 H5 声明 MIME;Rust 侧必须识别 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM bytes 头部,要求导入文件扩展名对应 MIME 与真实 bytes 匹配,导出 payload 的 `mimeType` 与 `base64Data` 解码 bytes 匹配。不匹配返回 `invalid_request`,继续不暴露本机绝对路径或通用文件系统能力。配置检查和 cargo test 覆盖该边界。
|
||||
|
||||
@@ -324,6 +324,8 @@ GameBridge 禁止:
|
||||
|
||||
2026-06-18 追加:移动壳 HostBridge 协议名和协议版本统一从 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_PROTOCOL` / `HOST_BRIDGE_VERSION` 读取。Expo 入口 query、WebView 事件注入、`host.getRuntime` 回包和 Expo public config smoke 都必须反查共享常量;移动壳配置检查会拒绝重新写死 `GenarrativeHostBridge` 或字面量版本。
|
||||
|
||||
2026-06-18 追加:桌面壳 HostBridge 协议名和协议版本也必须反查同一共享契约。Tauri Rust 侧仍保留 `host_bridge/protocol.rs` 常量作为运行时代码入口,但 `apps/desktop-shell/scripts/check-config.mjs` 会把 Rust `HOST_BRIDGE_PROTOCOL` / `HOST_BRIDGE_VERSION` 与 `packages/shared/src/contracts/hostBridge.ts` 对齐,避免桌面壳事件注入、runtime 回包和 H5 transport 分叉。
|
||||
|
||||
2026-06-18 追加:移动壳默认 H5 地址固定为 `https://app.genarrative.world/`。开发联调如需加载本机 Vite,必须显式设置 `EXPO_PUBLIC_GENARRATIVE_WEB_URL=http://127.0.0.1:3000/`、`http://localhost:3000/` 或 `http://[::1]:3000/`;生产包不得在未配置环境变量时默认加载设备本机 localhost,也不得通过环境变量把第三方外域 H5 放入带完整 HostBridge 的 WebView。
|
||||
|
||||
2026-06-18 追加:移动壳系统深链声明固定为生产主站唯一入口。iOS `associatedDomains` 只能包含 `applinks:app.genarrative.world`;Android `intentFilters` 只能存在一个 `VIEW` / `autoVerify=true` 的 App Link 过滤器,category 只能是 `BROWSABLE` 和 `DEFAULT`,data 只能绑定 `https://app.genarrative.world`,不得额外声明外域、明文协议、pathPattern 或其它可接管范围。实际 deep link 解析仍由壳层把同源路径归一后附加 HostBridge 上下文,非法来源回退默认首页。
|
||||
@@ -387,9 +389,9 @@ GameBridge 禁止:
|
||||
|
||||
2026-06-18 追加:桌面壳默认不接入崩溃上报、analytics、遥测日志或渠道分发 SDK。`apps/desktop-shell/scripts/check-config.mjs` 会拒绝 Sentry、Datadog、PostHog、Segment、Amplitude、Bugsnag、OpenTelemetry、Tauri log / updater 等 Node / Cargo 依赖、`package-lock.json` / `Cargo.lock` 解析包和 Rust 初始化片段;后续只有在真实采集端点、数据字段、用户授权、隐私披露、签名和发布流程确定后,才能按单项能力补充方案与实现。
|
||||
|
||||
2026-06-18 追加:桌面壳 release / dev 入口 URL 的 `hostVersion` 必须与 Tauri `tauri.conf.json`、Node package 和 Cargo package 版本一致;`host.getRuntime` 回包继续使用 `env!("CARGO_PKG_VERSION")`,配置检查会拒绝入口 query 版本、Tauri 配置版本或 Rust runtime 版本来源分叉,避免桌面包升级时 H5 首屏上下文与 runtime 回读不一致。
|
||||
2026-06-18 追加:桌面壳 release / dev 在 `tauri.conf.json` 中只保留基础入口 `index.html` 和 `http://127.0.0.1:3000/`,由 Rust `shell/url.rs` 统一补写 `clientRuntime=native_app`、`hostShell=tauri_desktop`、`hostVersion`、`bridgeVersion` 和真实 `hostCapabilities`。`hostVersion` 必须与 Tauri `tauri.conf.json`、Node package 和 Cargo package 版本一致,`host.getRuntime` 回包继续使用 `env!("CARGO_PKG_VERSION")`;配置检查会拒绝在 Tauri 配置里重新手写宿主上下文,避免桌面包升级或能力变化时 H5 首屏上下文与 runtime 回读不一致。
|
||||
|
||||
2026-06-18 追加:桌面壳静态 Tauri 配置中的 `hostPlatform=unknown` 只作为跨平台构建模板值。Rust `setup` 手动创建主窗口前会把入口 URL 归一为当前 `macos` / `windows` / `linux`,保证 H5 首屏 query 与 `host.getRuntime` 回读的平台一致;不通过第二实例参数、外部 deep link 或 H5 自报值覆盖该平台字段。
|
||||
2026-06-18 追加:桌面壳静态 Tauri 配置不再写入 `hostPlatform` 或其它宿主上下文 query。Rust `setup` 手动创建主窗口前会把基础入口归一为当前 `macos` / `windows` / `linux` 平台和完整宿主上下文,保证 H5 首屏 query 与 `host.getRuntime` 回读的平台一致;不通过第二实例参数、外部 deep link 或 H5 自报值覆盖该平台字段。
|
||||
|
||||
2026-06-18 追加:桌面壳主 WebView 增加顶层导航边界。打包资产 URL 和 `https://app.genarrative.world` 同源 H5 route 可以继续留在主窗口;外域 `http:` / `https:`、`mailto:`、`tel:` 导航和 `window.open` 请求只交给系统 opener 后阻止留壳;`javascript:`、`file:` 等危险协议直接阻断。该规则不新增 HostBridge capability,也不开放 opener 插件 JS guest API,避免外域页面停留在带 `host_bridge_request` 权限的主 WebView 内。
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from 'node:path';
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { HOST_BRIDGE_CAPABILITIES } from '../../packages/shared/src/contracts/hostBridge.ts';
|
||||
import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js';
|
||||
|
||||
const protocolPath = path.resolve(
|
||||
@@ -39,6 +40,11 @@ describe('wechat mini program host bridge protocol index', () => {
|
||||
'share.setTarget',
|
||||
'share.open',
|
||||
]);
|
||||
expect(
|
||||
protocol.WECHAT_HOST_CAPABILITIES.filter(
|
||||
(capability) => !HOST_BRIDGE_CAPABILITIES.includes(capability),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(protocol.WECHAT_AUTH_PAGE_URL).toBe(
|
||||
'/pages/web-view/index?authAction=login&returnTo=previous',
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user