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 <cursoragent@cursor.com>
This commit is contained in:
Kevin
2026-05-19 15:43:16 +08:00
parent 3921c5ec24
commit 6d281c92a5
13 changed files with 275 additions and 18 deletions

View File

@@ -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`。
# CIGitHub Actions 在构建 APK 前会按分支调用 use-envmain → stagingtag → production
# iOS 本机 TestFlightnpm 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

View File

@@ -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

View File

@@ -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

View File

@@ -29,7 +29,28 @@ const LOCALES: Record<string, LocaleMessages> = {
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<PermissionKey, string> = {
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',

View File

@@ -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",

View File

@@ -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;

View File

@@ -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") <staging|production|development>" >&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 <<EOF
Done. In Xcode:
• Select "Any iOS Device" (or a connected device)
• Product → Archive
• Window → Organizer → Distribute App → App Store Connect
Environment: ${ENV}
API URL: $(grep -E '^EXPO_PUBLIC_API_URL=' .env 2>/dev/null | cut -d= -f2- || echo '(see .env)')
EOF

View File

@@ -2,7 +2,7 @@
* 将 app-expo/.env.<name> 复制为 .env供 Metro/Expo 读取 EXPO_PUBLIC_*。
*
* 参数 name → 源文件:
* development → .env.development本地默认:npm start / prestart
* development → .env.development仓库已提交;npm start / prestart 默认
* staging → .env.staging
* production → .env.production
*

View File

@@ -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 (
<View className="flex-1 bg-background">
<ScreenHeader title="关于" />
<ScreenHeader title={t('about.title')} />
<SafeAreaView className="flex-1 items-center justify-center gap-4 px-6">
<Text variant="h2" className="text-foreground">
{t('about.appName')}
</Text>
<Text className="text-muted-foreground">Life Echo</Text>
<Text className="text-sm text-muted-foreground"> {version}</Text>
<Text className="text-muted-foreground">{t('about.appSubtitle')}</Text>
<Text className="text-sm text-muted-foreground">
{t('about.version', { version })}
</Text>
{showBackend ? (
<View className="w-full max-w-sm items-center gap-1">
<Text className="text-sm text-muted-foreground">
{t('about.backend')}
</Text>
<Text
selectable
className="text-center font-mono text-xs text-muted-foreground"
>
{config.apiBaseUrl}
</Text>
</View>
) : null}
<Text className="mt-8 text-center text-sm leading-5 text-muted-foreground">
{t('about.tagline')}
</Text>
</SafeAreaView>
</View>

View File

@@ -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,

View File

@@ -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",

View File

@@ -1,7 +1,12 @@
{
"about": {
"aboutUs": "关于我们",
"title": "关于"
"appName": "岁月时书",
"appSubtitle": "Life Echo",
"backend": "连接的后端",
"tagline": "记录你的人生故事,让回忆成书。",
"title": "关于",
"version": "版本 {{version}}"
},
"appExperience": {
"language": "语言",

View File

@@ -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),
);
});
});