加固原生壳生产入口门禁
桌面壳深链打开失败改为显式日志记录 移动壳生产导出校验共享主站 URL 原生壳方案文档同步生产入口验收规则
This commit is contained in:
@@ -2104,6 +2104,10 @@ const requiredRustHostSnippets = [
|
||||
'app.deep_link().get_current()',
|
||||
'app.deep_link().register_all()',
|
||||
'normalize_desktop_deep_link_url',
|
||||
'log_desktop_deep_link_open_result',
|
||||
'open_desktop_deep_link_url(window: &WebviewWindow, url: &Url) -> tauri::Result<()>',
|
||||
'window.navigate(target_url)?',
|
||||
'show_main_window(window.app_handle())',
|
||||
'DESKTOP_DEEP_LINK_HOSTS',
|
||||
'resolve_desktop_single_instance_action',
|
||||
'tauri_plugin_clipboard_manager::init()',
|
||||
@@ -2709,6 +2713,8 @@ for (const blockedLifecycleSnippet of [
|
||||
'let _ = emit_current_desktop_lifecycle_event(window)',
|
||||
'let _ = emit_current_desktop_lifecycle_event(&lifecycle_window)',
|
||||
'let _ = emit_desktop_image_drop_event(&drop_window',
|
||||
'let _ = window.navigate(target_url)',
|
||||
'let _ = show_main_window(window.app_handle())',
|
||||
]) {
|
||||
if (rustHostSource.includes(blockedLifecycleSnippet)) {
|
||||
throw new Error(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::shell::lifecycle::log_desktop_host_event_result;
|
||||
use crate::shell::tray::show_main_window;
|
||||
use crate::shell::webview::{desktop_h5_url_with_host_context, WEB_APP_ORIGIN};
|
||||
use tauri::{Manager, Url, WebviewWindow};
|
||||
@@ -62,13 +63,17 @@ pub(crate) fn normalize_desktop_deep_link_url(raw_url: &Url) -> Option<Url> {
|
||||
desktop_h5_url_with_host_context(target_url)
|
||||
}
|
||||
|
||||
fn open_desktop_deep_link_url(window: &WebviewWindow, url: &Url) {
|
||||
fn open_desktop_deep_link_url(window: &WebviewWindow, url: &Url) -> tauri::Result<()> {
|
||||
let Some(target_url) = normalize_desktop_deep_link_url(url) else {
|
||||
return;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let _ = window.navigate(target_url);
|
||||
let _ = show_main_window(window.app_handle());
|
||||
window.navigate(target_url)?;
|
||||
show_main_window(window.app_handle())
|
||||
}
|
||||
|
||||
fn log_desktop_deep_link_open_result(result: tauri::Result<()>) -> bool {
|
||||
log_desktop_host_event_result("deep_link.open", result)
|
||||
}
|
||||
|
||||
pub(crate) fn register_desktop_deep_link_events(app: &tauri::App) -> tauri::Result<()> {
|
||||
@@ -79,7 +84,7 @@ pub(crate) fn register_desktop_deep_link_events(app: &tauri::App) -> tauri::Resu
|
||||
};
|
||||
|
||||
for url in event.urls() {
|
||||
open_desktop_deep_link_url(&window, &url);
|
||||
log_desktop_deep_link_open_result(open_desktop_deep_link_url(&window, &url));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -88,7 +93,7 @@ pub(crate) fn register_desktop_deep_link_events(app: &tauri::App) -> tauri::Resu
|
||||
};
|
||||
if let Ok(Some(urls)) = app.deep_link().get_current() {
|
||||
for url in urls {
|
||||
open_desktop_deep_link_url(&window, &url);
|
||||
log_desktop_deep_link_open_result(open_desktop_deep_link_url(&window, &url));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,4 +230,12 @@ mod tests {
|
||||
Some("genarrative")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_deep_link_open_result_reports_success_and_failure() {
|
||||
assert!(log_desktop_deep_link_open_result(Ok(())));
|
||||
assert!(!log_desktop_deep_link_open_result(Err(
|
||||
tauri::Error::AssetNotFound("deep-link".to_string())
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,43 @@ import fs from 'node:fs';
|
||||
|
||||
const shellRoot = new URL('../', import.meta.url);
|
||||
const outputRoot = new URL('../.expo-export-smoke/', import.meta.url);
|
||||
const hostBridgeContractUrl = new URL(
|
||||
'../../../packages/shared/src/contracts/hostBridge.ts',
|
||||
import.meta.url,
|
||||
);
|
||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
const platforms = ['android', 'ios'];
|
||||
const blockedDevelopmentWebUrlPatterns = [
|
||||
/http:\\?\/\\?\/localhost(?::\d+)?/u,
|
||||
/http:\\?\/\\?\/127\.0\.0\.1(?::\d+)?/u,
|
||||
/http:\\?\/\\?\/\[::1\](?::\d+)?/u,
|
||||
];
|
||||
|
||||
function readHostBridgePublicWebUrl() {
|
||||
const contractSource = fs.readFileSync(hostBridgeContractUrl, 'utf8');
|
||||
const publicWebUrlMatch = contractSource.match(
|
||||
/HOST_BRIDGE_PUBLIC_WEB_URL\s*=\s*'([^']+)'/u,
|
||||
);
|
||||
const publicWebOriginMatch = contractSource.match(
|
||||
/HOST_BRIDGE_PUBLIC_WEB_ORIGIN\s*=\s*'([^']+)'/u,
|
||||
);
|
||||
|
||||
if (!publicWebUrlMatch || !publicWebOriginMatch) {
|
||||
throw new Error('HostBridge public web URL contract is missing');
|
||||
}
|
||||
|
||||
const publicWebUrl = publicWebUrlMatch[1];
|
||||
const publicWebOrigin = publicWebOriginMatch[1];
|
||||
if (new URL(publicWebUrl).origin !== publicWebOrigin) {
|
||||
throw new Error(
|
||||
'HostBridge public web URL and origin contract must point to the same origin',
|
||||
);
|
||||
}
|
||||
|
||||
return publicWebUrl;
|
||||
}
|
||||
|
||||
const expectedPublicWebUrl = readHostBridgePublicWebUrl();
|
||||
|
||||
function runExpoExport(platform) {
|
||||
const outputDir = `.expo-export-smoke/${platform}`;
|
||||
@@ -80,6 +115,21 @@ function assertBundle(platform, bundlePath) {
|
||||
if (!bundlePath.includes(`/static/js/${platform}/AppEntry-`)) {
|
||||
throw new Error(`Expo ${platform} bundle path does not target AppEntry`);
|
||||
}
|
||||
|
||||
const bundleSource = fs.readFileSync(bundleFile, 'utf8');
|
||||
if (!bundleSource.includes(expectedPublicWebUrl)) {
|
||||
throw new Error(
|
||||
`Expo ${platform} production bundle must include the shared public web URL`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const blockedPattern of blockedDevelopmentWebUrlPatterns) {
|
||||
if (blockedPattern.test(bundleSource)) {
|
||||
throw new Error(
|
||||
`Expo ${platform} production bundle must not include a local development H5 URL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.rmSync(outputRoot, {recursive: true, force: true});
|
||||
|
||||
@@ -2859,6 +2859,13 @@
|
||||
- 影响范围:`apps/desktop-shell/src-tauri/src/app.rs`、`apps/desktop-shell/src-tauri/src/shell/lifecycle.rs`、`apps/desktop-shell/src-tauri/src/shell/file_drop.rs`、`apps/desktop-shell/src-tauri/src/shell/webview.rs`、`apps/desktop-shell/scripts/check-config.mjs`、宿主壳能力统一协议文档。
|
||||
- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
## 2026-06-20 桌面壳深链打开失败不可静默
|
||||
|
||||
- 背景:`genarrative://` 和同源 HTTPS 深链是桌面壳的生产入口;如果深链归一成功后 `window.navigate(...)` 或恢复主窗口失败却被静默忽略,用户会看到深链无反应且没有可排查日志。
|
||||
- 决策:桌面壳深链打开必须让 `open_desktop_deep_link_url(...)` 返回 `tauri::Result<()>`,窗口导航和 `show_main_window(...)` 失败统一记录日志;配置检查拒绝深链模块继续使用 `let _ = window.navigate(...)` 或 `let _ = show_main_window(...)` 静默吞错。
|
||||
- 影响范围:`apps/desktop-shell/src-tauri/src/shell/deep_link.rs`、`apps/desktop-shell/scripts/check-config.mjs`、宿主壳能力统一协议文档。
|
||||
- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
## 2026-06-20 移动壳 ShellApp HostBridge 事件注入必须可执行覆盖
|
||||
|
||||
- 背景:Expo 移动壳声明 `host.events`、`app.lifecycle`、`network.statusChanged` 和 `navigation.canGoBack`,但 ShellApp 真实 AppState、Network 和 WebView 返回栈注入链路需要和扫码链路一样有可执行测试覆盖,不能只靠字符串门禁。
|
||||
|
||||
@@ -427,7 +427,9 @@ GameBridge 禁止:
|
||||
|
||||
2026-06-18 追加:桌面壳启用 Tauri 窗口状态持久化。`shell/window_state.rs` 只配置 `tauri-plugin-window-state` 保存主窗口大小、位置和最大化状态,并排除可见性、全屏和装饰状态;插件注册顺序固定为 single-instance 优先,其后才是窗口状态、deep-link 和其它系统能力插件。该能力不进入 HostBridge 清单,不向 H5 暴露任意窗口状态读写,也不改变托盘关闭隐藏、单实例唤醒和托盘恢复主窗口的既有语义。
|
||||
|
||||
2026-06-18 追加:桌面壳接入 Tauri deep-link 插件,但不开放插件 JS guest API,也不把 deep-link 命令加入 capability。桌面配置只注册 `genarrative` scheme;Rust 层只接受 `genarrative://open/...`、`genarrative://app/...`、`genarrative://<path>` 和 `https://app.genarrative.world/...`,统一跳到同源 H5 路径并补写 `clientRuntime=native_app`、`hostShell=tauri_desktop`、当前平台、版本与真实 `hostCapabilities`。外域、`http:`、`mailto:`、`javascript:`、`file:` 等来源不进入主 WebView。
|
||||
2026-06-18 追加:桌面壳接入 Tauri deep-link 插件,但不开放插件 JS guest API,也不把 deep-link 命令加入 capability。桌面配置只注册 `genarrative` scheme;Rust 层只接受 `genarrative://open/...`、`genarrative://app/...`、`genarrative://<path>` 和 `https://app.genarrative.world/...`,统一跳到同源 H5 路径并补写 `clientRuntime=native_app`、`hostShell=tauri_desktop`、当前平台、版本与真实 `hostCapabilities`。外域、`http:`、`mailto:`、`javascript:`、`file:` 等来源不进入主 WebView;归一成功后的 `window.navigate(...)` 与主窗口恢复 / 聚焦失败必须记录桌面宿主事件日志,不能静默吞错。
|
||||
|
||||
2026-06-20 追加:移动壳 production bundle smoke 必须读取 Metro 导出的 iOS / Android JS bundle,并确认 bundle 内包含共享 HostBridge 契约中的生产 H5 URL,且不包含 `http://localhost`、`http://127.0.0.1` 或 `http://[::1]` 这类本机开发 H5 入口。开发态仍允许 `EXPO_PUBLIC_GENARRATIVE_WEB_URL` 指向本机 Vite,但可分发包验收不能让本机 URL 混入生产 bundle。
|
||||
|
||||
2026-06-18 追加:桌面壳安装包身份固定为 `world.genarrative.desktop`,产品名为 `Genarrative`,Tauri、Node package 与 Cargo package 版本统一为 `0.1.0`。Release 主窗口只能从打包进二进制的 `index.html` 进入根 `dist` H5 资产,dev URL 只能指向本机 Vite 调试入口;CSP 必须保持 `script-src 'self'`,不得加入 `unsafe-eval`、`tauri:` 或 `file:` 这类扩大桌面攻击面的来源。当前不配置自动更新器,直到存在真实更新端点、签名密钥和发布流程再接入;`apps/desktop-shell/scripts/check-config.mjs` 会校验这些包身份、版本、CSP 和 updater 禁用约束。
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ Tauri 桌面壳的文件能力边界统一在 `apps/desktop-shell/src-tauri/src/
|
||||
2. `authService` 保留原导出,但内部委托 HostBridge,避免一次性改动 AuthGate。
|
||||
3. 分享弹窗、分享目标同步、九宫切图、微信小程序支付和订阅授权改用 HostBridge 通用接口;旧微信命名服务只作为兼容导出。
|
||||
4. 后续新增 `native_app` adapter 时只补桥接实现和测试,业务层不新增平台分叉;主 App 启动会触发一次 `host.getRuntime` 回读并订阅能力变化,避免裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时长期隐藏真实可用能力。
|
||||
5. 每次新增或调整 native capability、HostBridge event 或宿主上下文 query 后,必须先更新 `packages/shared/src/contracts/hostBridge.ts` 中对应微信 / Expo / Tauri capability profile、事件白名单和 query 契约,再运行 `npm run check:native-shells`,统一覆盖 H5 HostBridge 关键测试、三端桥接层文件结构门禁、微信小程序页面路由与 H5 常量反查、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描。移动壳和桌面壳文档中的主状态段落能力清单与完整能力清单都必须反查共享 capability profile,避免同一文档内部漂移;桌面壳未声明的共享 request method 必须由 Rust 测试从 `HOST_BRIDGE_METHODS - capabilities()` 自动派生为 `unsupported_method` 覆盖清单,当前包括 `auth.requestLogin`、`payment.request`、`file.captureImage`、`scanner.scanQrCode` 和 `haptics.impact`,不得伪造成功;Expo 移动壳未声明的共享 request method 必须由 `HOST_BRIDGE_METHODS - HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES` 自动派生测试覆盖,确保未接 SDK / 渠道的 method 返回明确 `unsupported_method`;H5 facade 除 `host.getRuntime` 真实回读外,所有 native_app request 能力都必须通过 `canUseNativeHostCapability(...)` 统一门控,根级门禁会从共享 `HOST_BRIDGE_METHODS` 自动派生需检查清单;排查单端问题时再单独运行 `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、HostBridge event 或宿主上下文 query 后,必须先更新 `packages/shared/src/contracts/hostBridge.ts` 中对应微信 / Expo / Tauri capability profile、事件白名单和 query 契约,再运行 `npm run check:native-shells`,统一覆盖 H5 HostBridge 关键测试、三端桥接层文件结构门禁、微信小程序页面路由与 H5 常量反查、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描。移动壳 Metro export smoke 必须读取 iOS / Android production bundle,确认最终 bundle 使用共享 HostBridge 契约中的生产 H5 URL,且没有混入本机开发 H5 URL。移动壳和桌面壳文档中的主状态段落能力清单与完整能力清单都必须反查共享 capability profile,避免同一文档内部漂移;桌面壳未声明的共享 request method 必须由 Rust 测试从 `HOST_BRIDGE_METHODS - capabilities()` 自动派生为 `unsupported_method` 覆盖清单,当前包括 `auth.requestLogin`、`payment.request`、`file.captureImage`、`scanner.scanQrCode` 和 `haptics.impact`,不得伪造成功;Expo 移动壳未声明的共享 request method 必须由 `HOST_BRIDGE_METHODS - HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES` 自动派生测试覆盖,确保未接 SDK / 渠道的 method 返回明确 `unsupported_method`;H5 facade 除 `host.getRuntime` 真实回读外,所有 native_app request 能力都必须通过 `canUseNativeHostCapability(...)` 统一门控,根级门禁会从共享 `HOST_BRIDGE_METHODS` 自动派生需检查清单;排查单端问题时再单独运行 `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`。
|
||||
|
||||
## 验收
|
||||
|
||||
@@ -102,7 +102,7 @@ Tauri 桌面壳的文件能力边界统一在 `apps/desktop-shell/src-tauri/src/
|
||||
- 小程序支付仍跳转 `/pages/wechat-pay/index` 并保留支付结果 hash 回灌确认。
|
||||
- 小程序订阅授权仍跳转 `/pages/subscribe-message/index`,且返回不阻断生成主链路。
|
||||
- 普通浏览器分享、H5 支付和 Native 二维码支付不受影响。
|
||||
- 原生壳统一验收入口 `npm run check:native-shells` 通过,能力白名单、微信 / Expo / Tauri 共享 capability profile、HostBridge event 白名单、宿主上下文 query 契约、壳 runtime 回包、URL `hostCapabilities`、H5 fallback、微信小程序 `app.json.pages` 与 `protocol.js` 页面 URL、H5 小程序页面常量、H5 订阅授权页面常量、WebView 分享入口、分享目标消息类型、WebView source query、微信请求头来源标记、H5 路由保留字段、生产 / 开发 H5 与 API HTTPS 域名格式、三端桥接层结构、两端壳实现、Expo managed config、移动端 production bundle、桌面 release 构建入口,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描没有漂移;扫描范围包含微信小程序壳生产 `.js`、Tauri `Info.plist`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件。H5 业务文件允许正常表单 `placeholder` 属性、业务占位图文案和真实兼容 / 故障语义中的“未实现”“临时”表述,但不得出现 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹。
|
||||
- 原生壳统一验收入口 `npm run check:native-shells` 通过,能力白名单、微信 / Expo / Tauri 共享 capability profile、HostBridge event 白名单、宿主上下文 query 契约、壳 runtime 回包、URL `hostCapabilities`、H5 fallback、微信小程序 `app.json.pages` 与 `protocol.js` 页面 URL、H5 小程序页面常量、H5 订阅授权页面常量、WebView 分享入口、分享目标消息类型、WebView source query、微信请求头来源标记、H5 路由保留字段、生产 / 开发 H5 与 API HTTPS 域名格式、三端桥接层结构、两端壳实现、Expo managed config、移动端 production bundle 主站 URL、桌面 release 构建入口,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描没有漂移;扫描范围包含微信小程序壳生产 `.js`、Tauri `Info.plist`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件。H5 业务文件允许正常表单 `placeholder` 属性、业务占位图文案和真实兼容 / 故障语义中的“未实现”“临时”表述,但不得出现 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹。
|
||||
|
||||
## 后续
|
||||
|
||||
|
||||
Reference in New Issue
Block a user