From 6d281c92a59778fbec043476b16dc90b3e4baf28 Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 19 May 2026 15:43:16 +0800 Subject: [PATCH] feat(app-expo): env variants, local iOS prebuild, and About diagnostics Align staging/production builds with APP_VARIANT bundle IDs, allow staging HTTP on iOS, add ios-prebuild scripts for TestFlight, and show connected API URL on About for non-production builds. Co-authored-by: Cursor --- app-expo/.env.example | 26 ++++++++- app-expo/.env.production | 5 +- app-expo/.env.staging | 7 ++- app-expo/app.config.ts | 32 +++++++++-- app-expo/package.json | 3 + app-expo/plugins/withIosInsecureHttp.js | 63 +++++++++++++++++++++ app-expo/scripts/ios-prebuild.sh | 67 +++++++++++++++++++++++ app-expo/scripts/use-env.js | 2 +- app-expo/src/app/(main)/about.tsx | 29 ++++++++-- app-expo/src/core/config.ts | 22 ++++++++ app-expo/src/i18n/locales/en/profile.json | 7 ++- app-expo/src/i18n/locales/zh/profile.json | 7 ++- app-expo/tests/core/config.test.ts | 23 ++++++++ 13 files changed, 275 insertions(+), 18 deletions(-) create mode 100644 app-expo/plugins/withIosInsecureHttp.js create mode 100755 app-expo/scripts/ios-prebuild.sh create mode 100644 app-expo/tests/core/config.test.ts diff --git a/app-expo/.env.example b/app-expo/.env.example index 8774536..cf03025 100644 --- a/app-expo/.env.example +++ b/app-expo/.env.example @@ -1,12 +1,34 @@ # 复制为 .env.development / .env.staging / .env.production 后填写(勿提交含密钥的副本)。 +# 仓库已提交三份模板:.env.development、.env.staging、.env.production。 # 本地:npm start 会通过 prestart 执行 `use-env development` 生成 .env; # 或手动 `npm run use-env -- staging` / `npm run use-env -- production`。 # CI:GitHub Actions 在构建 APK 前会按分支调用 use-env(main → staging,tag → production)。 +# iOS 本机 TestFlight:npm run ios:prebuild:staging|production(见 scripts/ios-prebuild.sh)。 +# +# APP_VARIANT / EXPO_PUBLIC_APP_VARIANT:控制 Bundle ID 与「关于」页是否显示后端地址 +# development / staging → 显示版本 + API 地址;production → 仅版本号 # # 变量在构建时注入;修改后需重新 prebuild/打包客户端。 # # 助手朗读:无独立 EXPO_PUBLIC_* TTS 开关。会话页顶栏在每轮 WebSocket 中带 `tts_this_turn`; # 服务端是否具备合成能力见 api/.env 中 ENABLE_TTS 等(模板见 api/.env.example)。 -EXPO_PUBLIC_API_URL=https://your-api.example.com -EXPO_PUBLIC_WS_URL=wss://your-api.example.com +# --- development(本地)--- +# APP_VARIANT=development +# EXPO_PUBLIC_APP_VARIANT=development +# EXPO_PUBLIC_API_URL=http://127.0.0.1:8000 +# EXPO_PUBLIC_WS_URL=ws://127.0.0.1:8000 + +# --- staging(预发 / TestFlight staging 包)--- +# APP_VARIANT=staging +# EXPO_PUBLIC_APP_VARIANT=staging +# iOS Bundle ID: org.brighteng.lifecho.staging +# EXPO_PUBLIC_API_URL=http://your-staging-host:8000 +# EXPO_PUBLIC_WS_URL=ws://your-staging-host:8000 + +# --- production(正式)--- +# APP_VARIANT=production +# EXPO_PUBLIC_APP_VARIANT=production +# iOS Bundle ID: org.brighteng.lifecho +# EXPO_PUBLIC_API_URL=https://your-api.example.com +# EXPO_PUBLIC_WS_URL=wss://your-api.example.com diff --git a/app-expo/.env.production b/app-expo/.env.production index d8b30e0..886120c 100644 --- a/app-expo/.env.production +++ b/app-expo/.env.production @@ -1,3 +1,6 @@ -# 仅 API/WS 基址;TTS 每轮开关由运行时 WS payload 与服务端 ENABLE_TTS 控制(见 api/.env.example)。 +# 正式:关于页仅显示版本号;iOS Bundle ID org.brighteng.lifecho +# TTS 每轮开关由运行时 WS payload 与服务端 ENABLE_TTS 控制(见 api/.env.example)。 +APP_VARIANT=production +EXPO_PUBLIC_APP_VARIANT=production EXPO_PUBLIC_API_URL=https://lifecho.worldsplats.com EXPO_PUBLIC_WS_URL=wss://lifecho.worldsplats.com diff --git a/app-expo/.env.staging b/app-expo/.env.staging index 7382083..83b33fa 100644 --- a/app-expo/.env.staging +++ b/app-expo/.env.staging @@ -1,2 +1,5 @@ -EXPO_PUBLIC_API_URL=http://1.15.29.57:8000/ -EXPO_PUBLIC_WS_URL=ws://1.15.29.57:8000/ +# 预发:关于页显示版本 + 后端地址;iOS Bundle ID org.brighteng.lifecho.staging +APP_VARIANT=staging +EXPO_PUBLIC_APP_VARIANT=staging +EXPO_PUBLIC_API_URL=http://1.15.29.57:8000 +EXPO_PUBLIC_WS_URL=ws://1.15.29.57:8000 diff --git a/app-expo/app.config.ts b/app-expo/app.config.ts index 97f74a9..3931de8 100644 --- a/app-expo/app.config.ts +++ b/app-expo/app.config.ts @@ -29,7 +29,28 @@ const LOCALES: Record = { const SUPPORTED_LOCALES = ['zh', 'en'] as const; const PRIMARY_LOCALE = process.env.EXPO_PUBLIC_PRIMARY_LOCALE ?? 'zh'; const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? ''; -const ALLOW_ANDROID_CLEARTEXT_TRAFFIC = API_BASE_URL.startsWith('http://'); +const ALLOW_INSECURE_HTTP = API_BASE_URL.startsWith('http://'); + +const APP_VARIANT = + process.env.APP_VARIANT ?? + process.env.EXPO_PUBLIC_APP_VARIANT ?? + 'development'; +const IS_STAGING = APP_VARIANT === 'staging'; +const IS_PRODUCTION = APP_VARIANT === 'production'; + +const IOS_BUNDLE_IDENTIFIER = IS_STAGING + ? 'org.brighteng.lifecho.staging' + : IS_PRODUCTION + ? 'org.brighteng.lifecho' + : 'com.anonymous.app-expo'; + +const ANDROID_PACKAGE = IS_STAGING + ? 'org.brighteng.lifecho.staging' + : IS_PRODUCTION + ? 'org.brighteng.lifecho' + : 'com.anonymous.appexpo'; + +const APP_DISPLAY_NAME = IS_STAGING ? 'Life Echo (Staging)' : 'Life Echo'; const PERMISSION_FALLBACKS: Record = { microphone: 'Allow $(PRODUCT_NAME) to access your microphone.', @@ -106,7 +127,7 @@ export default ({ config }: ConfigContext): ExpoConfig => { return { ...config, - name: 'Life Echo', + name: APP_DISPLAY_NAME, slug: 'life-echo', version: '1.2.0', orientation: 'portrait', @@ -116,7 +137,7 @@ export default ({ config }: ConfigContext): ExpoConfig => { ios: { ...config?.ios, icon: './assets/images/icon.png', - bundleIdentifier: 'com.anonymous.app-expo', + bundleIdentifier: IOS_BUNDLE_IDENTIFIER, config: { usesNonExemptEncryption: false, }, @@ -140,7 +161,7 @@ export default ({ config }: ConfigContext): ExpoConfig => { */ softwareKeyboardLayoutMode: 'resize', // Reverse-DNS; no hyphens (Android package name rules). Matches iOS bundle id intent. - package: 'com.anonymous.appexpo', + package: ANDROID_PACKAGE, adaptiveIcon: { backgroundColor: '#E6F4FE', foregroundImage: './assets/images/android-icon-foreground.png', @@ -153,8 +174,9 @@ export default ({ config }: ConfigContext): ExpoConfig => { './plugins/withAndroidReleaseSigning', [ './plugins/withAndroidCleartextTraffic', - { enabled: ALLOW_ANDROID_CLEARTEXT_TRAFFIC }, + { enabled: ALLOW_INSECURE_HTTP }, ], + ['./plugins/withIosInsecureHttp', { enabled: ALLOW_INSECURE_HTTP }], 'expo-router', [ 'expo-splash-screen', diff --git a/app-expo/package.json b/app-expo/package.json index efa95db..bb68eb7 100644 --- a/app-expo/package.json +++ b/app-expo/package.json @@ -11,6 +11,9 @@ "reset-project": "node ./scripts/reset-project.js", "android": "npm run use-env -- development && expo run:android", "ios": "npm run use-env -- development && expo run:ios", + "ios:prebuild": "bash scripts/ios-prebuild.sh staging", + "ios:prebuild:staging": "bash scripts/ios-prebuild.sh staging", + "ios:prebuild:production": "bash scripts/ios-prebuild.sh production", "web": "npm run use-env -- development && expo start --web", "lint": "expo lint", "test": "jest --watch", diff --git a/app-expo/plugins/withIosInsecureHttp.js b/app-expo/plugins/withIosInsecureHttp.js new file mode 100644 index 0000000..9e9810c --- /dev/null +++ b/app-expo/plugins/withIosInsecureHttp.js @@ -0,0 +1,63 @@ +// @ts-check +/** + * Allow HTTP / WS to staging API host via App Transport Security exception. + * + * Enabled when EXPO_PUBLIC_API_URL uses http:// (same rule as Android cleartext). + * Host is parsed from the URL so IP:port staging endpoints work without hard-coding. + */ +const { withInfoPlist } = require('@expo/config-plugins'); + +/** + * @returns {string | null} + */ +function getHttpExceptionHost() { + const raw = process.env.EXPO_PUBLIC_API_URL ?? ''; + if (!raw.startsWith('http://')) { + return null; + } + try { + return new URL(raw).hostname; + } catch { + return null; + } +} + +/** + * @param {import('expo/config').ExpoConfig} config + * @param {{ enabled?: boolean }} props + */ +function withIosInsecureHttp(config, props = {}) { + const enabled = props.enabled ?? false; + + return withInfoPlist(config, (mod) => { + if (!enabled) { + return mod; + } + + const host = getHttpExceptionHost(); + if (!host) { + console.warn( + '[withIosInsecureHttp] enabled but EXPO_PUBLIC_API_URL has no http host; skipping ATS exception.', + ); + return mod; + } + + const existing = mod.modResults.NSAppTransportSecurity ?? {}; + const existingDomains = existing.NSExceptionDomains ?? {}; + + mod.modResults.NSAppTransportSecurity = { + ...existing, + NSExceptionDomains: { + ...existingDomains, + [host]: { + NSExceptionAllowsInsecureHTTPLoads: true, + NSIncludesSubdomains: true, + }, + }, + }; + + return mod; + }); +} + +module.exports = withIosInsecureHttp; diff --git a/app-expo/scripts/ios-prebuild.sh b/app-expo/scripts/ios-prebuild.sh new file mode 100755 index 0000000..415c362 --- /dev/null +++ b/app-expo/scripts/ios-prebuild.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# 本机 iOS Release:切换 env → expo prebuild → 打开 Xcode 打 Archive 上传 TestFlight。 +# +# 用法(在仓库任意目录): +# app-expo/scripts/ios-prebuild.sh staging +# app-expo/scripts/ios-prebuild.sh production +# +# 或通过 npm(在 app-expo 目录): +# npm run ios:prebuild:staging +# npm run ios:prebuild:production +# +# Xcode 内后续步骤(脚本不会自动执行): +# 1. 选 Any iOS Device(或 Generic iOS Device) +# 2. Product → Archive +# 3. Distribute App → App Store Connect → Upload(进入 TestFlight) + +set -euo pipefail + +ENV="${1:-}" +if [[ -z "$ENV" ]]; then + echo "Usage: $(basename "$0") " >&2 + exit 1 +fi + +case "$ENV" in + staging | production | development) ;; + *) + echo "Unknown environment: $ENV (expected staging, production, or development)" >&2 + exit 1 + ;; +esac + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +echo "==> Switching to .env.${ENV}" +npm run use-env -- "$ENV" + +echo "==> expo prebuild --platform ios --clean" +npx expo prebuild --platform ios --clean + +shopt -s nullglob +WORKSPACES=(ios/*.xcworkspace) +shopt -u nullglob + +if [[ ${#WORKSPACES[@]} -eq 0 ]]; then + echo "No ios/*.xcworkspace found after prebuild." >&2 + exit 1 +fi + +if [[ ${#WORKSPACES[@]} -gt 1 ]]; then + echo "Multiple workspaces found; opening the first: ${WORKSPACES[0]}" >&2 +fi + +echo "==> Opening ${WORKSPACES[0]}" +open "${WORKSPACES[0]}" + +cat < 复制为 .env,供 Metro/Expo 读取 EXPO_PUBLIC_*。 * * 参数 name → 源文件: - * development → .env.development(本地默认:npm start / prestart) + * development → .env.development(仓库已提交;npm start / prestart 默认) * staging → .env.staging * production → .env.production * diff --git a/app-expo/src/app/(main)/about.tsx b/app-expo/src/app/(main)/about.tsx index 312be8c..37db148 100644 --- a/app-expo/src/app/(main)/about.tsx +++ b/app-expo/src/app/(main)/about.tsx @@ -1,25 +1,44 @@ import Constants from 'expo-constants'; import React from 'react'; import { View } from 'react-native'; +import { useTranslation } from 'react-i18next'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Text } from '@/components/ui/text'; import { ScreenHeader } from '@/components/screen-header'; +import { config } from '@/core/config'; export default function AboutScreen() { + const { t } = useTranslation('profile'); const version = Constants.expoConfig?.version ?? '1.0.0'; + const showBackend = config.showAboutBackendUrl; return ( - + - 岁月时书 + {t('about.appName')} - Life Echo - 版本 {version} + {t('about.appSubtitle')} + + {t('about.version', { version })} + + {showBackend ? ( + + + {t('about.backend')} + + + {config.apiBaseUrl} + + + ) : null} - 记录你的人生故事,让回忆成书。 + {t('about.tagline')} diff --git a/app-expo/src/core/config.ts b/app-expo/src/core/config.ts index 36d3525..67c98ef 100644 --- a/app-expo/src/core/config.ts +++ b/app-expo/src/core/config.ts @@ -2,6 +2,26 @@ function trimTrailingSlashes(value: string): string { return value.replace(/\/+$/, ''); } +export type AppVariant = 'development' | 'staging' | 'production'; + +function resolveAppVariant(): AppVariant { + const raw = process.env.EXPO_PUBLIC_APP_VARIANT; + if (raw === 'development' || raw === 'staging' || raw === 'production') { + return raw; + } + if (__DEV__) { + return 'development'; + } + return 'production'; +} + +/** Shown on About screen for dev/staging builds only. */ +export function shouldShowAboutBackendUrl(variant: AppVariant = appVariant): boolean { + return variant === 'development' || variant === 'staging'; +} + +export const appVariant = resolveAppVariant(); + export const config = { apiBaseUrl: trimTrailingSlashes( process.env.EXPO_PUBLIC_API_URL ?? 'http://192.168.10.151:8000', @@ -10,6 +30,8 @@ export const config = { process.env.EXPO_PUBLIC_WS_URL ?? 'ws://192.168.10.151:8000', ), isDebugMode: __DEV__, + appVariant, + showAboutBackendUrl: shouldShowAboutBackendUrl(), api: { timeoutMs: 30_000, diff --git a/app-expo/src/i18n/locales/en/profile.json b/app-expo/src/i18n/locales/en/profile.json index 99072ce..d042542 100644 --- a/app-expo/src/i18n/locales/en/profile.json +++ b/app-expo/src/i18n/locales/en/profile.json @@ -1,7 +1,12 @@ { "about": { "aboutUs": "About Us", - "title": "About" + "appName": "Life Echo", + "appSubtitle": "岁月时书", + "backend": "API endpoint", + "tagline": "Capture your life story and turn memories into a book.", + "title": "About", + "version": "Version {{version}}" }, "appExperience": { "language": "Language", diff --git a/app-expo/src/i18n/locales/zh/profile.json b/app-expo/src/i18n/locales/zh/profile.json index 22007b7..e9e05fd 100644 --- a/app-expo/src/i18n/locales/zh/profile.json +++ b/app-expo/src/i18n/locales/zh/profile.json @@ -1,7 +1,12 @@ { "about": { "aboutUs": "关于我们", - "title": "关于" + "appName": "岁月时书", + "appSubtitle": "Life Echo", + "backend": "连接的后端", + "tagline": "记录你的人生故事,让回忆成书。", + "title": "关于", + "version": "版本 {{version}}" }, "appExperience": { "language": "语言", diff --git a/app-expo/tests/core/config.test.ts b/app-expo/tests/core/config.test.ts new file mode 100644 index 0000000..22cfa71 --- /dev/null +++ b/app-expo/tests/core/config.test.ts @@ -0,0 +1,23 @@ +import { + appVariant, + config, + shouldShowAboutBackendUrl, + type AppVariant, +} from '@/core/config'; + +describe('shouldShowAboutBackendUrl', () => { + it('shows backend URL for development and staging', () => { + expect(shouldShowAboutBackendUrl('development')).toBe(true); + expect(shouldShowAboutBackendUrl('staging')).toBe(true); + }); + + it('hides backend URL for production', () => { + expect(shouldShowAboutBackendUrl('production')).toBe(false); + }); + + it('matches config.showAboutBackendUrl for current build', () => { + expect(config.showAboutBackendUrl).toBe( + shouldShowAboutBackendUrl(appVariant as AppVariant), + ); + }); +});