diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 76fad6a59..c50fbac83 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -53,6 +53,11 @@ const desktopHostBridgeDispatchSource = fs.readFileSync( desktopHostBridgeDispatchPath, 'utf8', ); +const desktopShellUrlPath = new URL( + '../src-tauri/src/shell/url.rs', + import.meta.url, +); +const desktopShellUrlSource = fs.readFileSync(desktopShellUrlPath, 'utf8'); const productionSourceRoots = [ new URL('../package.json', import.meta.url), new URL('../src-tauri/Cargo.toml', import.meta.url), @@ -932,6 +937,10 @@ const sharedHostBridgeVersion = extractTsNumberConst( sharedContractSource, 'HOST_BRIDGE_VERSION', ); +const sharedPublicWebOrigin = extractTsStringConst( + sharedContractSource, + 'HOST_BRIDGE_PUBLIC_WEB_ORIGIN', +); const sharedHostBridgePayloadLimits = { HOST_BRIDGE_REQUEST_ID_MAX_LENGTH: extractTsNumberConst( sharedContractSource, @@ -1041,6 +1050,10 @@ const desktopHostBridgeVersion = extractRustNumberConst( rustHostSource, 'HOST_BRIDGE_VERSION', ); +const desktopPublicWebOrigin = extractRustStringConst( + desktopShellUrlSource, + 'WEB_APP_ORIGIN', +); const desktopCapabilities = extractDesktopCapabilities( desktopHostBridgeCapabilitiesSource, ); @@ -1060,6 +1073,12 @@ if (desktopHostBridgeVersion !== sharedHostBridgeVersion) { ); } +if (desktopPublicWebOrigin !== sharedPublicWebOrigin) { + throw new Error( + `desktop shell public web origin drifted: expected ${sharedPublicWebOrigin} but got ${desktopPublicWebOrigin}`, + ); +} + for (const [limitName, sharedLimit] of Object.entries( sharedHostBridgePayloadLimits, )) { diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 975536e94..b09b14e08 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -489,6 +489,14 @@ const sharedHostBridgeVersion = extractNumberConstExport( sharedContractSource, 'HOST_BRIDGE_VERSION', ); +const sharedPublicWebOrigin = extractStringConstExport( + sharedContractSource, + 'HOST_BRIDGE_PUBLIC_WEB_ORIGIN', +); +const sharedPublicWebUrl = extractStringConstExport( + sharedContractSource, + 'HOST_BRIDGE_PUBLIC_WEB_URL', +); const handledMobileMethods = extractMobileBridgeHandledMethods(dispatchSource); const mobileCapabilities = extractStringArrayExport( hostBridgeSource, @@ -926,16 +934,11 @@ if ( ); } -if ( - !urlSource.includes( - "DEFAULT_MOBILE_SHELL_WEB_URL = 'https://app.genarrative.world/'", - ) -) { - throw new Error('mobile shell default H5 URL must point to the production web app'); -} - for (const snippet of [ - "ALLOWED_PRODUCTION_WEB_ORIGIN = 'https://app.genarrative.world'", + 'HOST_BRIDGE_PUBLIC_WEB_ORIGIN', + 'HOST_BRIDGE_PUBLIC_WEB_URL', + 'DEFAULT_MOBILE_SHELL_WEB_URL = HOST_BRIDGE_PUBLIC_WEB_URL', + 'ALLOWED_PRODUCTION_WEB_ORIGIN = HOST_BRIDGE_PUBLIC_WEB_ORIGIN', 'LOCAL_DEVELOPMENT_WEB_HOSTS', "'127.0.0.1'", "'localhost'", @@ -947,6 +950,23 @@ for (const snippet of [ } } +for (const localWebOriginDuplicate of [ + `DEFAULT_MOBILE_SHELL_WEB_URL = '${sharedPublicWebUrl}'`, + `ALLOWED_PRODUCTION_WEB_ORIGIN = '${sharedPublicWebOrigin}'`, +]) { + if (urlSource.includes(localWebOriginDuplicate)) { + throw new Error('mobile shell public web origin must come from shared HostBridge contract'); + } +} + +if (!webViewPolicySource.includes('DEFAULT_MOBILE_SHELL_WEB_URL')) { + throw new Error('mobile shell WebView policy must use the shared default H5 URL'); +} + +if (webViewPolicySource.includes(`'${sharedPublicWebUrl}'`)) { + throw new Error('mobile shell WebView policy must not duplicate the public web URL'); +} + for (const snippet of [ 'ALLOWED_PRODUCTION_WEB_ORIGIN', 'normalizePublicShareUrl', diff --git a/apps/mobile-shell/src/shell/url.test.ts b/apps/mobile-shell/src/shell/url.test.ts index 7827bc16a..f26b053b0 100644 --- a/apps/mobile-shell/src/shell/url.test.ts +++ b/apps/mobile-shell/src/shell/url.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from 'vitest'; -import { HOST_BRIDGE_VERSION } from '../../../../packages/shared/src/contracts/hostBridge'; +import { + HOST_BRIDGE_PUBLIC_WEB_ORIGIN, + HOST_BRIDGE_PUBLIC_WEB_URL, + HOST_BRIDGE_VERSION, +} from '../../../../packages/shared/src/contracts/hostBridge'; import { ALLOWED_PRODUCTION_WEB_ORIGIN, DEFAULT_MOBILE_SHELL_WEB_URL, @@ -10,8 +14,8 @@ import { describe('buildMobileShellUrl', () => { test('默认 H5 地址指向真实主站', () => { - expect(DEFAULT_MOBILE_SHELL_WEB_URL).toBe('https://app.genarrative.world/'); - expect(ALLOWED_PRODUCTION_WEB_ORIGIN).toBe('https://app.genarrative.world'); + expect(DEFAULT_MOBILE_SHELL_WEB_URL).toBe(HOST_BRIDGE_PUBLIC_WEB_URL); + expect(ALLOWED_PRODUCTION_WEB_ORIGIN).toBe(HOST_BRIDGE_PUBLIC_WEB_ORIGIN); }); test('为 H5 附加原生移动壳上下文', () => { diff --git a/apps/mobile-shell/src/shell/url.ts b/apps/mobile-shell/src/shell/url.ts index c8f4c7319..214043dc1 100644 --- a/apps/mobile-shell/src/shell/url.ts +++ b/apps/mobile-shell/src/shell/url.ts @@ -1,4 +1,6 @@ import { + HOST_BRIDGE_PUBLIC_WEB_ORIGIN, + HOST_BRIDGE_PUBLIC_WEB_URL, HOST_BRIDGE_VERSION, type HostBridgeCapability, type NativeHostPlatform, @@ -10,8 +12,8 @@ export type MobileShellUrlOptions = { capabilities: HostBridgeCapability[]; }; -export const DEFAULT_MOBILE_SHELL_WEB_URL = 'https://app.genarrative.world/'; -export const ALLOWED_PRODUCTION_WEB_ORIGIN = 'https://app.genarrative.world'; +export const DEFAULT_MOBILE_SHELL_WEB_URL = HOST_BRIDGE_PUBLIC_WEB_URL; +export const ALLOWED_PRODUCTION_WEB_ORIGIN = HOST_BRIDGE_PUBLIC_WEB_ORIGIN; const LOCAL_DEVELOPMENT_WEB_HOSTS = new Set([ '127.0.0.1', 'localhost', diff --git a/apps/mobile-shell/src/shell/webViewPolicy.ts b/apps/mobile-shell/src/shell/webViewPolicy.ts index f070ebc4f..fa4e9b991 100644 --- a/apps/mobile-shell/src/shell/webViewPolicy.ts +++ b/apps/mobile-shell/src/shell/webViewPolicy.ts @@ -1,3 +1,5 @@ +import { DEFAULT_MOBILE_SHELL_WEB_URL } from './url'; + const MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS = new Set([ 'blob:', 'data:', @@ -18,7 +20,7 @@ export function shouldBlockMobileWebViewDownloadUrl( try { return MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS.has( - new URL(rawUrl, 'https://app.genarrative.world/').protocol, + new URL(rawUrl, DEFAULT_MOBILE_SHELL_WEB_URL).protocol, ); } catch { return false; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 81d32b5b7..5a0d0cc84 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -55,6 +55,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-19 公开 Web origin 单一来源:原生壳允许加载 / 分享 / 跳转的公开 H5 主站 origin 以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_PUBLIC_WEB_ORIGIN` / `HOST_BRIDGE_PUBLIC_WEB_URL` 为源;Expo 移动壳只能通过 `DEFAULT_MOBILE_SHELL_WEB_URL` / `ALLOWED_PRODUCTION_WEB_ORIGIN` 语义别名引用共享常量,Tauri 桌面壳 `WEB_APP_ORIGIN` 作为 Rust 运行时镜像常量必须由 `apps/desktop-shell/scripts/check-config.mjs` 反查同一共享值。两端不得在分享、WebView policy、启动 URL 或桌面导航里另行复刻 `https://app.genarrative.world` 作为独立真相。 - 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 配置。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 9617eb2a3..3b75cc126 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -326,6 +326,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-19 追加:公开 H5 主站 origin / 默认 URL 统一以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_PUBLIC_WEB_ORIGIN` / `HOST_BRIDGE_PUBLIC_WEB_URL` 为源。移动壳 `DEFAULT_MOBILE_SHELL_WEB_URL` 与 `ALLOWED_PRODUCTION_WEB_ORIGIN` 只是壳层语义别名,分享 URL 归一、WebView 下载协议判定和启动 URL 回退都不得重新写死 `https://app.genarrative.world`;桌面 Rust 侧 `WEB_APP_ORIGIN` 保留为运行时镜像常量,但 `apps/desktop-shell/scripts/check-config.mjs` 必须反查共享 origin。两端配置检查会拒绝本地复刻或漂移同值 origin。 + 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。 diff --git a/packages/shared/src/contracts/hostBridge.test.ts b/packages/shared/src/contracts/hostBridge.test.ts index 5b8010608..17296db94 100644 --- a/packages/shared/src/contracts/hostBridge.test.ts +++ b/packages/shared/src/contracts/hostBridge.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from 'vitest'; import { + HOST_BRIDGE_PUBLIC_WEB_ORIGIN, + HOST_BRIDGE_PUBLIC_WEB_URL, HOST_BRIDGE_TAURI_COMMAND, isHostBridgeMethod, isHostBridgeCapability, @@ -17,6 +19,11 @@ import { } from './hostBridge'; describe('HostBridge shared contract helpers', () => { + test('固定公开 H5 主站入口', () => { + expect(HOST_BRIDGE_PUBLIC_WEB_ORIGIN).toBe('https://app.genarrative.world'); + expect(HOST_BRIDGE_PUBLIC_WEB_URL).toBe('https://app.genarrative.world/'); + }); + test('固定 Tauri 只暴露唯一 HostBridge command', () => { expect(HOST_BRIDGE_TAURI_COMMAND).toBe('host_bridge_request'); }); diff --git a/packages/shared/src/contracts/hostBridge.ts b/packages/shared/src/contracts/hostBridge.ts index 1adb65a80..0db9ce1ee 100644 --- a/packages/shared/src/contracts/hostBridge.ts +++ b/packages/shared/src/contracts/hostBridge.ts @@ -1,6 +1,8 @@ export const HOST_BRIDGE_PROTOCOL = 'GenarrativeHostBridge'; export const HOST_BRIDGE_VERSION = 1; export const HOST_BRIDGE_TAURI_COMMAND = 'host_bridge_request'; +export const HOST_BRIDGE_PUBLIC_WEB_ORIGIN = 'https://app.genarrative.world'; +export const HOST_BRIDGE_PUBLIC_WEB_URL = 'https://app.genarrative.world/'; export type HostShellKind = 'browser' | 'wechat_mini_program' | 'native_app';