320 lines
13 KiB
Bash
Executable File
320 lines
13 KiB
Bash
Executable File
#!/bin/bash
|
||
|
||
# 出错即停
|
||
set -e
|
||
|
||
# 自动使用 .fvmrc 中指定的 Flutter 版本(避免使用 PATH 里的系统 flutter 导致依赖冲突)
|
||
if [ -f "./.fvmrc" ]; then
|
||
FVM_VERSION=$(grep -o '"flutter"[[:space:]]*:[[:space:]]*"[^"]*"' .fvmrc | sed -E 's/.*"([^"]+)"$/\1/')
|
||
if [ -n "$FVM_VERSION" ] && [ -x "$HOME/fvm/versions/$FVM_VERSION/bin/flutter" ]; then
|
||
export PATH="$HOME/fvm/versions/$FVM_VERSION/bin:$PATH"
|
||
echo "🔧 使用 FVM Flutter 版本: $FVM_VERSION"
|
||
fi
|
||
fi
|
||
|
||
# 参数说明
|
||
if [ $# -lt 1 ]; then
|
||
printf -- "Build hgdj release package.\n\n"
|
||
printf -- "Usage: $0 <target> [mode] [export_method] [export_options_plist] [sign_mode]\n"
|
||
printf -- " target : a(android) | i(ios) | all\n"
|
||
printf -- " mode : release(默认) | debug | profile\n"
|
||
printf -- " export_method (iOS) : app-store(默认) | ad-hoc | development | enterprise\n"
|
||
printf -- " export_options_plist : ExportOptions.plist 路径(iOS 可选,留空用默认)\n"
|
||
printf -- " sign_mode (iOS) : codesign(默认) | nocodesign\n\n"
|
||
printf -- "Examples:\n"
|
||
printf -- " $0 a # Android release\n"
|
||
printf -- " $0 a debug # Android debug\n"
|
||
printf -- " $0 i # iOS release + app-store\n"
|
||
printf -- " $0 i release ad-hoc # iOS release + ad-hoc\n"
|
||
printf -- " $0 i release app-store ios/ExportOptions.plist\n"
|
||
printf -- " $0 i release app-store \"\" nocodesign # iOS 不签名出包\n"
|
||
printf -- " $0 all # Android + iOS release\n"
|
||
exit 1
|
||
fi
|
||
|
||
TARGET=$1
|
||
MODE=${2:-release}
|
||
EXPORT_METHOD=${3:-app-store}
|
||
EXPORT_OPTIONS_PLIST=${4:-}
|
||
SIGN_MODE=${5:-codesign}
|
||
|
||
# 参数校验
|
||
if [ "$TARGET" != "a" ] && [ "$TARGET" != "i" ] && [ "$TARGET" != "all" ]; then
|
||
echo "❌ 参数错误:target 只支持 a | i | all,你传的是 '$TARGET'"
|
||
exit 1
|
||
fi
|
||
|
||
if [ "$MODE" != "release" ] && [ "$MODE" != "debug" ] && [ "$MODE" != "profile" ]; then
|
||
echo "❌ 参数错误:mode 只支持 release | debug | profile,你传的是 '$MODE'"
|
||
exit 1
|
||
fi
|
||
|
||
case "$EXPORT_METHOD" in
|
||
app-store|ad-hoc|development|enterprise) ;;
|
||
*)
|
||
echo "❌ 参数错误:export_method 只支持 app-store | ad-hoc | development | enterprise,你传的是 '$EXPORT_METHOD'"
|
||
exit 1
|
||
;;
|
||
esac
|
||
|
||
if [ "$SIGN_MODE" != "codesign" ] && [ "$SIGN_MODE" != "nocodesign" ]; then
|
||
echo "❌ 参数错误:sign_mode 只支持 codesign | nocodesign,你传的是 '$SIGN_MODE'"
|
||
exit 1
|
||
fi
|
||
|
||
if [ -n "$EXPORT_OPTIONS_PLIST" ] && [ ! -f "$EXPORT_OPTIONS_PLIST" ]; then
|
||
echo "❌ 未找到 ExportOptions.plist:$EXPORT_OPTIONS_PLIST"
|
||
exit 1
|
||
fi
|
||
|
||
# 项目根路径校验
|
||
if [ ! -f "./pubspec.yaml" ]; then
|
||
echo "❌ 请在项目根目录下执行此脚本"
|
||
exit 1
|
||
fi
|
||
|
||
PROJECT_PATH=$(pwd)
|
||
PUBSPEC_FILE="pubspec.yaml"
|
||
|
||
# 提取项目信息
|
||
APP_NAME=$(grep '^name:' "$PUBSPEC_FILE" | awk '{print $2}')
|
||
RAW_VERSION=$(grep '^version:' "$PUBSPEC_FILE" | awk '{print $2}')
|
||
SAFE_VERSION=${RAW_VERSION//+/_}
|
||
TIME_TAG=$(date +"%m%d-%H%M")
|
||
|
||
# 混淆 + 抽离调试信息:
|
||
# --obfuscate 混淆 Dart 符号;--split-debug-info 把调试信息(含 libapp.so 里
|
||
# 嵌入的本机绝对路径 .../huangguo_android/.dart_tool/.../dart_plugin_registrant.dart)
|
||
# 抽到单独目录,不再残留在产物里。反编译后搜不到本机用户名/工程路径。
|
||
# 仅 release/profile 生效(debug 为 JIT,不支持也无需)。symbols 目录务必保留、勿外传,
|
||
# 崩溃堆栈符号化要用它。
|
||
OBFUSCATE_ARGS=""
|
||
if [ "$MODE" = "release" ] || [ "$MODE" = "profile" ]; then
|
||
SYMBOLS_DIR="$(pwd)/build/symbols/${MODE}_${SAFE_VERSION}_${TIME_TAG}"
|
||
mkdir -p "$SYMBOLS_DIR"
|
||
# 注意:本项目已启用 BlackObfuscator,再叠加 Flutter --obfuscate 会导致启动黑屏,故关闭。
|
||
# 去除 libapp.so 内嵌构建路径改由出包后的 strip_build_path 二进制替换完成,与此无关。
|
||
OBFUSCATE_ARGS=""
|
||
# OBFUSCATE_ARGS="--obfuscate --split-debug-info=$SYMBOLS_DIR"
|
||
fi
|
||
|
||
# 读取应用内 Config.isDebug(lib/config/config.dart),决定产物命名标识:
|
||
# isDebug=true → 命名标识固定为 debug(如 hgdj_debug_xxxx),方便区分内测包
|
||
# isDebug=false → 正式环境,命名沿用构建模式(release/profile),保持不变
|
||
CONFIG_FILE="lib/config/config.dart"
|
||
CONFIG_DEBUG=$(grep -E 'static[[:space:]]+const[[:space:]]+isDebug[[:space:]]*=' "$CONFIG_FILE" 2>/dev/null | grep -oE 'true|false' | head -1)
|
||
if [ "$CONFIG_DEBUG" = "true" ]; then
|
||
BUILD_TAG="debug"
|
||
elif [ "$CONFIG_DEBUG" = "false" ]; then
|
||
BUILD_TAG="$MODE"
|
||
else
|
||
echo "⚠️ 未能从 $CONFIG_FILE 解析 Config.isDebug,命名回退为构建模式:$MODE"
|
||
BUILD_TAG="$MODE"
|
||
fi
|
||
|
||
DIST_DIR="./dist"
|
||
mkdir -p "$DIST_DIR"
|
||
|
||
# 打印信息并二次确认
|
||
echo "=========================================="
|
||
echo "📦 准备打包"
|
||
echo "📌 项目名: $APP_NAME"
|
||
echo "📌 版本号: $RAW_VERSION"
|
||
echo "📌 目标平台: $TARGET"
|
||
echo "📌 构建模式: $MODE"
|
||
echo "📌 Config.isDebug: ${CONFIG_DEBUG:-未知} → 命名标识: $BUILD_TAG"
|
||
if [ "$TARGET" = "i" ] || [ "$TARGET" = "all" ]; then
|
||
echo "📌 iOS 导出: $EXPORT_METHOD"
|
||
echo "📌 iOS 签名: $SIGN_MODE"
|
||
if [ -n "$EXPORT_OPTIONS_PLIST" ]; then
|
||
echo "📌 ExportOptions.plist: $EXPORT_OPTIONS_PLIST"
|
||
fi
|
||
fi
|
||
echo "📌 工程路径: $PROJECT_PATH"
|
||
echo "=========================================="
|
||
echo "确认无误?(按回车继续,Ctrl+C 取消)"
|
||
read -r CONFIRM
|
||
|
||
# 清除 libapp.so 里嵌入的本机工程绝对路径(.../<工程>/.dart_tool/.../dart_plugin_registrant.dart)
|
||
# 做法:对该 URI 字符串做「等长」替换为中性路径(不含用户名/工程名),再 zipalign + 用原 keystore 重签。
|
||
# 等长替换保证 Dart String 长度字段不变、运行时安全;该 URI 仅为元数据,不参与运行逻辑。
|
||
strip_build_path() {
|
||
local APK="$1"
|
||
[ -f "$APK" ] || return 0
|
||
local BT; BT=$(ls -d "$HOME/Library/Android/sdk/build-tools/"* 2>/dev/null | sort -V | tail -1)
|
||
# 优先用 gradle 指定的 keystore(android/keystore/app-release.jks),避免误选到同目录备份
|
||
local KS=""
|
||
for cand in android/keystore/app-release.jks android/app/../keystore/app-release.jks; do
|
||
[ -f "$cand" ] && { KS="$cand"; break; }
|
||
done
|
||
[ -z "$KS" ] && KS=$(find android -name '*.jks' ! -name '*old*' ! -name '*bak*' 2>/dev/null | head -1)
|
||
local SP KP KA
|
||
SP=$(grep -E "storePassword" android/app/build.gradle | head -1 | sed -E "s/.*['\"]([^'\"]+)['\"].*/\1/")
|
||
KP=$(grep -E "keyPassword" android/app/build.gradle | head -1 | sed -E "s/.*['\"]([^'\"]+)['\"].*/\1/")
|
||
KA=$(grep -E "keyAlias" android/app/build.gradle | head -1 | sed -E "s/.*['\"]([^'\"]+)['\"].*/\1/")
|
||
if [ -z "$BT" ] || [ -z "$KS" ] || [ -z "$SP" ] || [ -z "$KA" ]; then
|
||
echo "⚠️ 跳过路径清理:缺少 build-tools / keystore / 签名信息"; return 0
|
||
fi
|
||
echo "🧹 清理 libapp.so 内嵌构建路径..."
|
||
local TMP; TMP=$(mktemp -d)
|
||
PROJ="$(pwd)" python3 - "$APK" "$TMP/patched.apk" <<'PY'
|
||
import sys, os, zipfile
|
||
from urllib.parse import quote
|
||
src, dst = sys.argv[1], sys.argv[2]
|
||
TAIL = b'/.dart_tool/flutter_build/dart_plugin_registrant.dart'
|
||
proj = os.environ["PROJ"]
|
||
# Flutter 以百分号编码嵌入非 ASCII 路径段;精确构造「当前工程」那条完整字节串
|
||
needle = quote(proj, safe="/").encode() + TAIL
|
||
# 等长中性替换:保留结构性尾巴,头部用 /aaa.../ 填满到同样长度(不含用户名/工程名)
|
||
repl = b'/' + b'a' * (len(needle) - 1 - len(TAIL)) + TAIL
|
||
assert len(repl) == len(needle)
|
||
def patch(d):
|
||
c = d.count(needle)
|
||
return (d.replace(needle, repl), c) if c else (d, 0)
|
||
zin = zipfile.ZipFile(src); zout = zipfile.ZipFile(dst, 'w'); total = 0
|
||
for it in zin.infolist():
|
||
data = zin.read(it.filename)
|
||
if it.filename.endswith('/libapp.so'):
|
||
data, n = patch(data); total += n
|
||
zi = zipfile.ZipInfo(it.filename, date_time=it.date_time)
|
||
zi.compress_type = it.compress_type; zi.external_attr = it.external_attr
|
||
zi.internal_attr = it.internal_attr; zi.create_system = it.create_system
|
||
zout.writestr(zi, data)
|
||
zout.close(); zin.close()
|
||
print(" 精确替换次数:", total)
|
||
if total == 0:
|
||
print(" ⚠️ 未找到工程路径字节串,未改动(宁可保留也不乱替换)")
|
||
PY
|
||
"$BT/zipalign" -f -p 4 "$TMP/patched.apk" "$TMP/aligned.apk" >/dev/null 2>&1 \
|
||
&& "$BT/apksigner" sign --ks "$KS" --ks-pass "pass:$SP" --ks-key-alias "$KA" --key-pass "pass:${KP:-$SP}" \
|
||
--v1-signing-enabled false --v2-signing-enabled true --out "$TMP/signed.apk" "$TMP/aligned.apk" >/dev/null 2>&1 \
|
||
&& "$BT/apksigner" verify "$TMP/signed.apk" >/dev/null 2>&1
|
||
if [ $? -eq 0 ]; then
|
||
cp "$TMP/signed.apk" "$APK"; echo "✅ 已清理并重签:$APK"
|
||
else
|
||
echo "⚠️ 清理失败,保留原包未改动:$APK"
|
||
fi
|
||
rm -rf "$TMP"
|
||
}
|
||
|
||
# Android 构建
|
||
build_android() {
|
||
echo "▶️ 开始构建 Android $MODE 包..."
|
||
|
||
if [ "$MODE" = "release" ]; then
|
||
flutter build apk --release --target-platform android-arm64 $OBFUSCATE_ARGS
|
||
APK_SOURCE="build/app/outputs/flutter-apk/app-release.apk"
|
||
elif [ "$MODE" = "profile" ]; then
|
||
flutter build apk --profile --target-platform android-arm64 $OBFUSCATE_ARGS
|
||
APK_SOURCE="build/app/outputs/flutter-apk/app-profile.apk"
|
||
else
|
||
flutter build apk --debug --target-platform android-arm64
|
||
APK_SOURCE="build/app/outputs/flutter-apk/app-debug.apk"
|
||
fi
|
||
|
||
if [ ! -f "$APK_SOURCE" ]; then
|
||
echo "❌ 构建失败,未找到 APK:$APK_SOURCE"
|
||
exit 1
|
||
fi
|
||
|
||
APK_NAME="${APP_NAME}_${BUILD_TAG}_${SAFE_VERSION}_${TIME_TAG}.apk"
|
||
APK_DEST="${DIST_DIR}/${APK_NAME}"
|
||
cp "$APK_SOURCE" "$APK_DEST"
|
||
|
||
# 注意:本 App 有运行时自校验,任何出包后修改 libapp.so(即使等长精确替换并重签)都会
|
||
# 导致启动卡在启动页。故不再对产物做二进制补丁。去除内嵌构建路径改用“中性目录构建”方案。
|
||
# strip_build_path 函数保留备查,但默认不调用。
|
||
|
||
echo "✅ Android 构建成功!"
|
||
echo "📁 输出路径: $APK_DEST"
|
||
echo "📏 文件大小: $APK_SIZE"
|
||
LAST_BUILT_FILE="$APK_DEST"
|
||
}
|
||
|
||
# iOS 构建(基于 flutter build ipa,一步出 ipa)
|
||
build_ios() {
|
||
echo "▶️ 开始构建 iOS $MODE 包..."
|
||
|
||
if [ "$MODE" = "debug" ]; then
|
||
echo "⚠️ iOS 不支持 debug 模式打包,请使用 release 或 profile(debug 建议直接 flutter run)"
|
||
exit 1
|
||
fi
|
||
|
||
IPA_OUTPUT_DIR="build/ios/ipa"
|
||
ARCHIVE_OUTPUT_DIR="build/ios/archive"
|
||
|
||
if [ "$MODE" = "release" ]; then
|
||
if [ "$SIGN_MODE" = "nocodesign" ]; then
|
||
flutter build ipa --release --no-codesign $OBFUSCATE_ARGS
|
||
elif [ -n "$EXPORT_OPTIONS_PLIST" ]; then
|
||
flutter build ipa --release --export-options-plist="$EXPORT_OPTIONS_PLIST" $OBFUSCATE_ARGS
|
||
else
|
||
flutter build ipa --release --export-method "$EXPORT_METHOD" $OBFUSCATE_ARGS
|
||
fi
|
||
else
|
||
if [ "$SIGN_MODE" = "nocodesign" ]; then
|
||
flutter build ipa --profile --no-codesign $OBFUSCATE_ARGS
|
||
elif [ -n "$EXPORT_OPTIONS_PLIST" ]; then
|
||
flutter build ipa --profile --export-options-plist="$EXPORT_OPTIONS_PLIST" $OBFUSCATE_ARGS
|
||
else
|
||
flutter build ipa --profile --export-method "$EXPORT_METHOD" $OBFUSCATE_ARGS
|
||
fi
|
||
fi
|
||
|
||
# 查找产物 ipa
|
||
IPA_SOURCE=""
|
||
for candidate in "$IPA_OUTPUT_DIR"/*.ipa; do
|
||
if [ -f "$candidate" ]; then
|
||
IPA_SOURCE="$candidate"
|
||
break
|
||
fi
|
||
done
|
||
|
||
if [ -z "$IPA_SOURCE" ]; then
|
||
if [ "$SIGN_MODE" = "nocodesign" ]; then
|
||
if [ -d "$ARCHIVE_OUTPUT_DIR" ]; then
|
||
echo "⚠️ 未签名模式下 Flutter 通常不会导出可安装的 IPA"
|
||
echo "✅ 未签名构建产物目录:$ARCHIVE_OUTPUT_DIR"
|
||
echo "📌 如需可安装/可上传的 IPA,请在 Xcode 配好 Team 和证书后用 codesign 模式重新打包"
|
||
LAST_BUILT_FILE=""
|
||
return
|
||
fi
|
||
echo "❌ 未签名构建完成,但未找到 archive 目录:$ARCHIVE_OUTPUT_DIR"
|
||
exit 1
|
||
fi
|
||
echo "❌ 构建结束,但未找到 IPA 文件:$IPA_OUTPUT_DIR/*.ipa"
|
||
echo "📌 通常是签名证书、Provisioning Profile 或 ExportOptions 配置有问题"
|
||
echo "📌 可以先试不签名出包:$0 i $MODE $EXPORT_METHOD \"$EXPORT_OPTIONS_PLIST\" nocodesign"
|
||
exit 1
|
||
fi
|
||
|
||
# 与 apk 命名对齐:去掉冗余的 ios 标识(后缀已区分平台),导出方式去连字符(ad-hoc→adhoc)
|
||
IPA_NAME="${APP_NAME}_${BUILD_TAG}_${EXPORT_METHOD//-/}_${SAFE_VERSION}_${TIME_TAG}.ipa"
|
||
IPA_DEST="${DIST_DIR}/${IPA_NAME}"
|
||
cp "$IPA_SOURCE" "$IPA_DEST"
|
||
|
||
IPA_SIZE=$(du -h "$IPA_DEST" | awk '{print $1}')
|
||
echo "✅ iOS 构建成功!"
|
||
echo "📁 输出路径: $IPA_DEST"
|
||
echo "📏 文件大小: $IPA_SIZE"
|
||
LAST_BUILT_FILE="$IPA_DEST"
|
||
}
|
||
|
||
# 分发
|
||
case "$TARGET" in
|
||
a) build_android ;;
|
||
i) build_ios ;;
|
||
all) build_android; build_ios ;;
|
||
esac
|
||
|
||
# 自动在 Finder 中打开并选中产物(仅 macOS)
|
||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||
if [ -n "$LAST_BUILT_FILE" ] && [ -f "$LAST_BUILT_FILE" ]; then
|
||
open -R "$LAST_BUILT_FILE"
|
||
else
|
||
open "$DIST_DIR"
|
||
fi
|
||
fi
|
||
|
||
echo "🎉 完成"
|