补齐移动壳Expo配置烟测

新增移动壳 Expo managed config 解析检查

将 mobile-shell:config 纳入原生壳统一验收

同步宿主壳方案、能力协议和共享记忆验收口径
This commit is contained in:
2026-06-18 09:48:09 +08:00
parent 9f1a3fac7d
commit 21165481b8
9 changed files with 196 additions and 8 deletions
+1
View File
@@ -9,6 +9,7 @@
"android": "expo run:android",
"ios": "expo run:ios",
"test": "vitest run -c vitest.config.ts",
"config:smoke": "node scripts/check-expo-config.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs"
},
"dependencies": {
@@ -0,0 +1,179 @@
import {spawnSync} from 'node:child_process';
import fs from 'node:fs';
const appConfigPath = new URL('../app.json', import.meta.url);
const packagePath = new URL('../package.json', import.meta.url);
const shellRoot = new URL('../', import.meta.url);
const appConfig = JSON.parse(fs.readFileSync(appConfigPath, 'utf8')).expo;
const packageConfig = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const result = spawnSync(
npmCommand,
['exec', 'expo', 'config', '--', '--type', 'public', '--json'],
{
cwd: shellRoot,
encoding: 'utf8',
},
);
if (result.error) {
throw new Error(`failed to start Expo config smoke: ${result.error.message}`);
}
if (result.signal) {
throw new Error(`Expo config smoke was terminated by signal ${result.signal}`);
}
if ((result.status ?? 0) !== 0) {
process.stdout.write(result.stdout ?? '');
process.stderr.write(result.stderr ?? '');
process.exit(result.status ?? 1);
}
function parseExpoConfigOutput(output) {
const trimmed = output.trim();
if (trimmed.startsWith('{')) {
return JSON.parse(trimmed);
}
const jsonStart = output.indexOf('{');
if (jsonStart === -1) {
throw new Error('Expo config smoke did not print JSON output');
}
return JSON.parse(output.slice(jsonStart));
}
const expoConfig = parseExpoConfigOutput(result.stdout ?? '');
function assertEqual(actual, expected, label) {
if (actual !== expected) {
throw new Error(
`Expo config ${label} drifted: expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`,
);
}
}
function assertIncludes(list, expected, label) {
if (!Array.isArray(list) || !list.includes(expected)) {
throw new Error(`Expo config ${label} missing ${expected}`);
}
}
function findPlugin(name) {
return expoConfig.plugins?.find((plugin) =>
Array.isArray(plugin) ? plugin[0] === name : plugin === name,
);
}
assertEqual(expoConfig.name, 'Genarrative', 'name');
assertEqual(expoConfig.slug, 'genarrative-mobile-shell', 'slug');
assertEqual(expoConfig.scheme, 'genarrative', 'scheme');
assertEqual(expoConfig.version, packageConfig.version, 'version');
assertEqual(expoConfig.version, appConfig.version, 'app version');
assertEqual(expoConfig.orientation, 'portrait', 'orientation');
assertEqual(expoConfig.userInterfaceStyle, 'automatic', 'userInterfaceStyle');
assertEqual(expoConfig.icon, './assets/icon.png', 'icon');
assertEqual(expoConfig.splash?.image, './assets/icon.png', 'splash image');
assertEqual(expoConfig.splash?.resizeMode, 'contain', 'splash resize mode');
assertEqual(expoConfig.splash?.backgroundColor, '#fffdf9', 'splash background');
assertEqual(
expoConfig.extra?.genarrativeHostBridgeVersion,
1,
'HostBridge version',
);
assertEqual(
expoConfig.ios?.bundleIdentifier,
'world.genarrative.mobile',
'iOS bundle identifier',
);
assertEqual(expoConfig.ios?.buildNumber, '1', 'iOS build number');
assertIncludes(
expoConfig.ios?.associatedDomains,
'applinks:app.genarrative.world',
'iOS associated domains',
);
assertEqual(
expoConfig.ios?.infoPlist?.ITSAppUsesNonExemptEncryption,
false,
'iOS encryption export flag',
);
assertEqual(
expoConfig.ios?.infoPlist?.NSAppTransportSecurity?.NSAllowsArbitraryLoads,
false,
'iOS ATS arbitrary loads',
);
assertEqual(
expoConfig.android?.package,
'world.genarrative.mobile',
'Android package',
);
assertEqual(expoConfig.android?.versionCode, 1, 'Android versionCode');
assertEqual(
expoConfig.android?.usesCleartextTraffic,
false,
'Android cleartext traffic',
);
assertEqual(expoConfig.android?.allowBackup, false, 'Android backup flag');
assertIncludes(
expoConfig.android?.blockedPermissions,
'android.permission.RECORD_AUDIO',
'Android blocked permissions',
);
assertEqual(
expoConfig.android?.adaptiveIcon?.foregroundImage,
'./assets/icon.png',
'Android adaptive icon foreground',
);
assertEqual(
expoConfig.android?.adaptiveIcon?.backgroundColor,
'#fffdf9',
'Android adaptive icon background',
);
const appLinkFilter = expoConfig.android?.intentFilters?.find((filter) =>
filter?.data?.some(
(entry) =>
entry?.scheme === 'https' && entry?.host === 'app.genarrative.world',
),
);
if (!appLinkFilter) {
throw new Error('Expo config Android app link filter is missing');
}
const imagePickerPlugin = findPlugin('expo-image-picker');
if (!Array.isArray(imagePickerPlugin)) {
throw new Error('Expo config image picker plugin is missing options');
}
assertEqual(
typeof imagePickerPlugin[1]?.photosPermission,
'string',
'image picker photo permission text type',
);
assertEqual(
typeof imagePickerPlugin[1]?.cameraPermission,
'string',
'image picker camera permission text type',
);
assertEqual(
imagePickerPlugin[1]?.microphonePermission,
false,
'image picker microphone permission',
);
const notificationsPlugin = findPlugin('expo-notifications');
if (!Array.isArray(notificationsPlugin)) {
throw new Error('Expo config notifications plugin is missing options');
}
assertEqual(
notificationsPlugin[1]?.enableBackgroundRemoteNotifications,
false,
'background remote notifications',
);
console.log('[mobile-shell:expo-config] OK');
+1 -1
View File
@@ -24,7 +24,7 @@
微信小程序壳、未来原生 App 壳、固定内置玩法与 AI H5 沙箱之间的宿主能力边界见 [【前端架构】宿主壳能力统一协议-2026-06-17.md](./【前端架构】宿主壳能力统一协议-2026-06-17.md)。
移动端壳采用 Expo + React Native、桌面端壳采用 Tauri,并统一作为 HostBridge adapter 的分阶段方案见 [【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md](./【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md)。
当前首轮工程入口:`npm run mobile-shell:dev``npm run desktop-shell:dev`;统一验收入口:`npm run check:native-shells`。排查单端问题时再分别运行 `npm run mobile-shell:typecheck``npm run mobile-shell:test``npm run desktop-shell:typecheck``npm run desktop-shell:test`;排查桌面 release 打包入口时运行 `npm run desktop-shell:build -- --no-bundle`
当前首轮工程入口:`npm run mobile-shell:dev``npm run desktop-shell:dev`;统一验收入口:`npm run check:native-shells`。排查单端问题时再分别运行 `npm run mobile-shell:typecheck``npm run mobile-shell:test``npm run mobile-shell:config``npm run desktop-shell:typecheck``npm run desktop-shell:test`;排查桌面 release 打包入口时运行 `npm run desktop-shell:build -- --no-bundle`
本地通过 SSH alias 管理多台服务器、查看硬件 / systemd / HTTP 健康状态并执行受控服务启停的 egui 桌面工具见 [【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md)。
@@ -26,7 +26,7 @@
- 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 check:native-shells` 统一执行 H5 HostBridge 关键测试、Expo 壳 typecheck / test、Tauri 壳 typecheck / cargo test 和桌面 release `--no-bundle` 构建烟测;根级 `npm run check` 会在 lint、主站测试、构建和内容检查后继续执行该门禁,避免 HostBridge、两端壳和桌面 release 入口验收散落成容易漏跑的单项命令。
- 2026-06-18 原生壳统一验收门禁:根级 `npm run check:native-shells` 统一执行 H5 HostBridge 关键测试、Expo 壳 typecheck / test / config smoke、Tauri 壳 typecheck / cargo test 和桌面 release `--no-bundle` 构建烟测;根级 `npm run check` 会在 lint、主站测试、构建和内容检查后继续执行该门禁,避免 HostBridge、两端壳、Expo managed config 和桌面 release 入口验收散落成容易漏跑的单项命令。
- 2026-06-18 分享卡图片导出:新增 `file.exportImage` HostBridge capabilityH5 分享卡下载在 native app 中优先把 canvas 生成的 base64 图片交给宿主导出;Expo 壳写缓存图片后交给系统分享 / 保存面板,Tauri 壳通过系统保存对话框写入图片字节。该能力只接受 `image/png` / `image/jpeg` / `image/webp`、单次 5 MiB 内图片数据,成功只返回文件名和字节数,不暴露本机绝对路径;宿主未声明时保留浏览器下载。
- 2026-06-18 应用角标能力:新增 `app.setBadgeCount` HostBridge capabilityH5 只传 `0-99999` 整数并在宿主未声明时静默 fallback;Expo 壳只在 iOS 声明并通过 React Native `PushNotificationIOS` 设置应用图标角标,Android 不声明、不伪造成功;Tauri 壳通过主窗口 `set_badge_count` 设置任务栏角标,底层平台不支持时返回真实错误。
- 2026-06-18 草稿生成未读角标:平台壳层把“可见作品架里未读的草稿生成完成更新”同步到 `app.setBadgeCount`;同一草稿的 work/profile/session 等多个恢复 ID 只计 1,已读、失败、生成中和不可见草稿不计入。该角标只消费已有 HostBridge 能力,宿主不支持或设置失败不影响 H5 红点、作品架或后端状态。
@@ -216,7 +216,7 @@ npm run build
npm run check:native-shells
```
该命令会覆盖 H5 HostBridge 关键测试、Expo 壳 typecheck / test、Tauri 壳 typecheck / cargo test,并执行桌面壳 release `--no-bundle` 构建烟测,确认打包 H5 资产和 Tauri release 入口可编译。
该命令会覆盖 H5 HostBridge 关键测试、Expo 壳 typecheck / test / config smoke、Tauri 壳 typecheck / cargo test,并执行桌面壳 release `--no-bundle` 构建烟测,确认 Expo managed config、打包 H5 资产和 Tauri release 入口可编译。
内容检查:
@@ -229,7 +229,7 @@ GameBridge 禁止:
- 壳层只接受来自允许 origin / packaged asset 的消息。
- 每个请求必须有超时,重复 `id` 不得重复执行支付、登录等非幂等动作。
- 能力按 `capabilities` / `hostCapabilities` 下发,H5 会过滤未知能力,并根据声明结果决定是否展示入口、发起宿主请求或走 fallback;进入 `native_app` 后主 App 会再通过真实 `host.getRuntime` 回读一次宿主 runtime 并缓存能力,用来补齐裁剪壳或旧入口 URL 缺少 `hostCapabilities` 的场景。不能只凭 `native_app` 宿主类型假设能力可用。
- 壳能力声明与两端壳测试必须通过 `npm run check:native-shells` 统一校验;排查单端问题时可再分别运行 `npm run mobile-shell:typecheck``npm run mobile-shell:test``npm run desktop-shell:typecheck``npm run desktop-shell:test``npm run desktop-shell:build -- --no-bundle`。声明的 capability 必须存在于共享 HostBridge 白名单,壳 runtime 回包、H5 URL `hostCapabilities`、壳实现和桌面 release 构建入口不得漂移。
- 壳能力声明与两端壳测试必须通过 `npm run check:native-shells` 统一校验;排查单端问题时可再分别运行 `npm run mobile-shell:typecheck``npm run mobile-shell:test``npm run mobile-shell:config``npm run desktop-shell:typecheck``npm run desktop-shell:test``npm run desktop-shell:build -- --no-bundle`。声明的 capability 必须存在于共享 HostBridge 白名单,壳 runtime 回包、H5 URL `hostCapabilities`、壳实现、Expo managed config 和桌面 release 构建入口不得漂移。
- 宿主壳不得把长期 token、支付密钥或用户敏感资料回传给 H5。
- Tauri 禁止把 shell / fs 等高危插件作为默认能力暴露给主 WebView。
- RN WebView 禁止打开任意 URL 后仍保留完整 HostBridge;跳外链只允许 `http:``https:``mailto:``tel:`,并使用系统浏览器或降级能力,危险协议直接阻断。
@@ -276,7 +276,7 @@ GameBridge 禁止:
2026-06-18 追加:移动壳默认 H5 地址固定为 `https://app.genarrative.world/`。开发联调如需加载本机 Vite,必须显式设置 `EXPO_PUBLIC_GENARRATIVE_WEB_URL=http://127.0.0.1:3000/` 或其它允许的 `http:` / `https:` 地址;生产包不得在未配置环境变量时默认加载设备本机 localhost。
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` 发生静默漂移。当前仍不写入假商店上架信息、假更新端点或占位渠道 SDK 配置。
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 配置。
### Phase 3Tauri 桌面壳 MVP
@@ -313,6 +313,8 @@ GameBridge 禁止:
2026-06-18 追加:桌面壳 release 构建烟测进入统一验收。`npm run check:native-shells` 会在 H5 HostBridge、Expo 壳和 Tauri 单测通过后执行 `npm run desktop-shell:build -- --no-bundle`,确认根 `dist` H5 资产、Tauri release 入口、受控命令白名单、图标和 Rust release 编译可以共同产出桌面二进制;该烟测不生成平台安装包,避免把 Linux 本机缺少的系统打包器误判为 HostBridge 回归。
2026-06-18 追加:移动壳 Expo managed config 烟测进入统一验收。`npm run check:native-shells` 会执行 `npm run mobile-shell:config`,在 `apps/mobile-shell` 目录内调用 `expo config --type public --json`,校验 Expo CLI 实际解析结果中的包名、scheme、深链、ATS / cleartext / backup / 麦克风权限、启动页、adaptive icon、插件配置和 HostBridge 版本没有漂移。
2026-06-18 追加:两端壳的生产源码和配置禁止出现 mock / fake / placeholder / stub / TODO / FIXME 以及对应中文脚手架词;测试文件仍可使用 `vi.mock` 或等价测试替身。`apps/mobile-shell/scripts/check-config.mjs``apps/desktop-shell/scripts/check-config.mjs` 会扫描各自生产入口、配置和壳实现,防止把临时替身或占位文案带进可分发壳。
2026-06-18 追加:移动壳启动页与 Android adaptive icon 复用现有真实品牌图标 `apps/mobile-shell/assets/icon.png`,背景色固定为 H5 壳根背景 `#fffdf9`。该 PNG 是 1024x1024 RGBA 透明前景品牌资产,不新增占位图;Expo `splash` 使用同一图标 `contain` 展示,Android `adaptiveIcon.foregroundImage` 使用同一透明前景图,`check-config.mjs` 会校验图标尺寸、透明像素、启动页和 adaptive icon 配置。
@@ -351,7 +353,7 @@ GameBridge 禁止:
- AI sandbox 无法调用 HostBridge,也无法读取 H5 登录态。
- Tauri release 包不允许任意远端页面调用桌面命令。
- Expo WebView 外链离开主站后不保留完整 HostBridge。
- 根级验收入口 `npm run check:native-shells` 必须同时覆盖 H5 HostBridge 关键路径、Expo 壳 typecheck / test、Tauri 壳 typecheck / cargo test 和桌面 release `--no-bundle` 构建烟测。
- 根级验收入口 `npm run check:native-shells` 必须同时覆盖 H5 HostBridge 关键路径、Expo 壳 typecheck / test / config smoke、Tauri 壳 typecheck / cargo test 和桌面 release `--no-bundle` 构建烟测。
## 参考资料
@@ -70,7 +70,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、Tauri 壳 typecheck / cargo test 和桌面 release `--no-bundle` 构建烟测;排查单端问题时再单独运行 `npm run mobile-shell:typecheck``npm run mobile-shell:test``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、Tauri 壳 typecheck / cargo test 和桌面 release `--no-bundle` 构建烟测;排查单端问题时再单独运行 `npm run mobile-shell:typecheck``npm run mobile-shell:test``npm run mobile-shell:config``npm run desktop-shell:typecheck``npm run desktop-shell:test``npm run desktop-shell:build -- --no-bundle`
## 验收
@@ -79,7 +79,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、两端壳实现和桌面 release 构建入口没有漂移。
- 原生壳统一验收入口 `npm run check:native-shells` 通过,能力白名单、壳 runtime 回包、URL `hostCapabilities`、H5 fallback、两端壳实现、Expo managed config 和桌面 release 构建入口没有漂移。
## 后续
+1
View File
@@ -12,6 +12,7 @@
"mobile-shell:dev": "npm --prefix apps/mobile-shell run dev",
"mobile-shell:typecheck": "npm --prefix apps/mobile-shell run typecheck",
"mobile-shell:test": "npm --prefix apps/mobile-shell run test",
"mobile-shell:config": "npm --prefix apps/mobile-shell run config:smoke",
"desktop-shell:dev": "npm --prefix apps/desktop-shell run dev",
"desktop-shell:build": "npm --prefix apps/desktop-shell run build --",
"desktop-shell:typecheck": "npm --prefix apps/desktop-shell run typecheck",
+5
View File
@@ -31,6 +31,11 @@ const steps = [
command: npmCommand,
args: ['run', 'mobile-shell:test'],
},
{
label: 'mobile-shell-expo-config-smoke',
command: npmCommand,
args: ['run', 'mobile-shell:config'],
},
{
label: 'desktop-shell-typecheck',
command: npmCommand,