From e8bcd3ba8847e8ea4a1d70766a7dd376c8df4e82 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 13:52:37 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3=E7=A7=BB=E5=8A=A8=E5=A3=B3?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E6=B7=B1=E9=93=BE=E5=A3=B0=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移动壳源配置门禁锁定唯一 iOS associated domain Expo public config 烟测锁定唯一 Android App Link 过滤器 宿主壳方案文档补充深链接管范围约束 共享决策日志记录移动壳系统深链声明边界 --- apps/mobile-shell/scripts/check-config.mjs | 41 ++++++++++++++----- .../scripts/check-expo-config.mjs | 33 +++++++++++---- .../shared-memory/decision-log.md | 7 ++++ ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 2 + 4 files changed, 63 insertions(+), 20 deletions(-) diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index f67c0ab30..5704ae3b1 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -430,9 +430,11 @@ if ('releaseChannel' in appConfig || 'channel' in appConfig) { throw new Error('mobile shell must not configure an app release channel without a real release process'); } -if (!appConfig.ios?.associatedDomains?.includes('applinks:app.genarrative.world')) { - throw new Error('mobile shell iOS associated domain is missing'); -} +assertSameList( + appConfig.ios?.associatedDomains ?? [], + ['applinks:app.genarrative.world'], + 'mobile shell iOS associated domains', +); if (appConfig.ios?.bundleIdentifier !== 'world.genarrative.mobile') { throw new Error('mobile shell iOS bundle identifier must be world.genarrative.mobile'); @@ -495,15 +497,32 @@ if ( throw new Error('mobile shell Android adaptive icon must use the real brand icon and brand background'); } -const androidFilter = appConfig.android?.intentFilters?.find((filter) => - filter?.data?.some( - (entry) => - entry?.scheme === 'https' && - entry?.host === 'app.genarrative.world', - ), +const androidFilters = appConfig.android?.intentFilters ?? []; +if (androidFilters.length !== 1) { + throw new Error('mobile shell Android app link filter must be the only intent filter'); +} + +const [androidFilter] = androidFilters; +if (androidFilter.action !== 'VIEW' || androidFilter.autoVerify !== true) { + throw new Error('mobile shell Android app link filter must be a verified VIEW filter'); +} + +assertSameList( + androidFilter.category ?? [], + ['BROWSABLE', 'DEFAULT'], + 'mobile shell Android app link categories', ); -if (!androidFilter) { - throw new Error('mobile shell Android app link filter is missing'); + +const androidFilterData = androidFilter.data ?? []; +if ( + androidFilterData.length !== 1 || + androidFilterData[0]?.scheme !== 'https' || + androidFilterData[0]?.host !== 'app.genarrative.world' || + Object.keys(androidFilterData[0] ?? {}).some( + (key) => key !== 'scheme' && key !== 'host', + ) +) { + throw new Error('mobile shell Android app link data must only bind https://app.genarrative.world'); } if (appConfig.extra?.genarrativeHostBridgeVersion !== 1) { diff --git a/apps/mobile-shell/scripts/check-expo-config.mjs b/apps/mobile-shell/scripts/check-expo-config.mjs index 120477e2e..a64becb1f 100644 --- a/apps/mobile-shell/scripts/check-expo-config.mjs +++ b/apps/mobile-shell/scripts/check-expo-config.mjs @@ -115,9 +115,9 @@ assertEqual( 'iOS bundle identifier', ); assertEqual(expoConfig.ios?.buildNumber, '1', 'iOS build number'); -assertIncludes( +assertSameList( expoConfig.ios?.associatedDomains, - 'applinks:app.genarrative.world', + ['applinks:app.genarrative.world'], 'iOS associated domains', ); assertEqual( @@ -196,14 +196,29 @@ assertEqual( 'Android adaptive icon background', ); -const appLinkFilter = expoConfig.android?.intentFilters?.find((filter) => - filter?.data?.some( - (entry) => - entry?.scheme === 'https' && entry?.host === 'app.genarrative.world', - ), +const appLinkFilters = expoConfig.android?.intentFilters ?? []; +if (appLinkFilters.length !== 1) { + throw new Error('Expo config Android app link filter must be the only intent filter'); +} + +const [appLinkFilter] = appLinkFilters; +assertEqual(appLinkFilter.action, 'VIEW', 'Android app link action'); +assertEqual(appLinkFilter.autoVerify, true, 'Android app link autoVerify'); +assertSameList( + appLinkFilter.category, + ['BROWSABLE', 'DEFAULT'], + 'Android app link categories', ); -if (!appLinkFilter) { - throw new Error('Expo config Android app link filter is missing'); +if ( + !Array.isArray(appLinkFilter.data) || + appLinkFilter.data.length !== 1 || + appLinkFilter.data[0]?.scheme !== 'https' || + appLinkFilter.data[0]?.host !== 'app.genarrative.world' || + Object.keys(appLinkFilter.data[0] ?? {}).some( + (key) => key !== 'scheme' && key !== 'host', + ) +) { + throw new Error('Expo config Android app link data must only bind https://app.genarrative.world'); } const imagePickerPlugin = findPlugin('expo-image-picker'); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 80363cd26..b0490937f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2431,6 +2431,13 @@ - 影响范围:移动壳配置检查、根依赖边界和 Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +## 2026-06-18 移动壳系统深链声明收口 + +- 背景:移动壳已经通过运行时归一限制 deep link 只能进入同源 H5 路径,但 iOS associated domains 和 Android intent filter 也属于安装包级接管范围;如果后续只做“包含主站”校验,安装包可能额外接管外域、明文协议或更宽路径。 +- 决策:Expo 源配置和 Expo CLI public config 都必须把 iOS `associatedDomains` 固定为唯一 `applinks:app.genarrative.world`;Android `intentFilters` 固定为唯一 `VIEW` / `autoVerify=true` 的 App Link 过滤器,category 只能是 `BROWSABLE` 和 `DEFAULT`,data 只能包含 `scheme=https` 与 `host=app.genarrative.world`,不得声明额外 domain、protocol、pathPattern 或其它接管范围。运行时 deep link 继续只映射同源路径并附加 HostBridge 上下文。 +- 影响范围:`apps/mobile-shell/app.json`、`apps/mobile-shell/scripts/check-config.mjs`、`apps/mobile-shell/scripts/check-expo-config.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + ## 2026-06-18 原生 HostBridge 入站消息来源收口 - 背景:H5 主站会同时承载原生壳 HostBridge 和后续 AI H5 sandbox / GameBridge;如果 H5 侧只按 JSON envelope 识别 HostBridge response / event,sandbox iframe 可以构造同形 `postMessage` 干扰待处理宿主请求或伪造宿主事件。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 0ebd564ce..bd2066b12 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -295,6 +295,8 @@ GameBridge 禁止: 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 上下文,非法来源回退默认首页。 + 2026-06-18 追加:移动壳安装包身份固定为 `world.genarrative.mobile`。Expo `app.json` 中的 `ios.bundleIdentifier` 与 `android.package` 使用同一包标识,应用版本为 `0.1.0`,iOS `buildNumber` 从字符串 `"1"` 起步,Android `versionCode` 从整数 `1` 起步;后续每次生成可分发安装包时只递增构建号 / versionCode,产品版本号按发布节奏单独调整。`apps/mobile-shell/scripts/check-config.mjs` 会校验这些字段与 `package.json` 版本一致,避免 iOS、Android 和 H5 HostBridge `hostVersion` 发生静默漂移;`npm run mobile-shell:config` 会调用真实 Expo CLI 解析 public managed config,确认最终 Expo 配置仍保留同一包身份、深链、安全字段、插件权限和 HostBridge 版本。当前仍不写入假商店上架信息、假更新端点或占位渠道 SDK 配置。 2026-06-18 追加:移动壳 H5 入口 query 和 `host.getRuntime` 回包统一读取 `MOBILE_SHELL_HOST_VERSION`,该常量必须与 Expo `app.json` / `package.json` 版本一致。配置检查会拒绝在 `App.tsx` 或 `mobileHostBridge.ts` 内重新散落硬编码版本,避免升级移动安装包时 H5 首屏上下文和宿主 runtime 回读版本不一致。