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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
63
app-expo/plugins/withIosInsecureHttp.js
Normal file
63
app-expo/plugins/withIosInsecureHttp.js
Normal 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;
|
||||
67
app-expo/scripts/ios-prebuild.sh
Executable file
67
app-expo/scripts/ios-prebuild.sh
Executable 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
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
{
|
||||
"about": {
|
||||
"aboutUs": "关于我们",
|
||||
"title": "关于"
|
||||
"appName": "岁月时书",
|
||||
"appSubtitle": "Life Echo",
|
||||
"backend": "连接的后端",
|
||||
"tagline": "记录你的人生故事,让回忆成书。",
|
||||
"title": "关于",
|
||||
"version": "版本 {{version}}"
|
||||
},
|
||||
"appExperience": {
|
||||
"language": "语言",
|
||||
|
||||
23
app-expo/tests/core/config.test.ts
Normal file
23
app-expo/tests/core/config.test.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user