#!/usr/bin/env bash # # 把 Flutter 生成的 SPM 中间包的 iOS 平台版本,对齐到本工程的 deployment target。 # # ── 解决什么问题 ──────────────────────────────────────────────────────────── # Flutter 3.29+ 的 Swift Package Manager 集成会生成 # ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift # 所有插件都挂在这个包下面。**它生成时的平台版本写死是 Flutter 自己的最低版本 # (iOS 13.0)**,而本项目有十几个本地插件声明了 `.iOS("16.0")`。SPM 不允许依赖的 # 平台版本高于上层包,于是 Xcode 报: # # The package product 'xxx' requires minimum platform version 16.0 # for the iOS platform, but this target supports 13.0 # # Flutter 自己有补救(SwiftPackageManager.updateMinimumDeployment:读 Runner 的 # IPHONEOS_DEPLOYMENT_TARGET 把生成包改写上去),但**只在 `flutter build/run` 里跑**。 # 而 Xcode 直接构建时,SPM 解析发生在所有 Flutter 步骤之前,看到的还是 13.0 —— 所以 # `flutter build ios` 能过、Xcode 里点构建就炸。 # # ⚠️ 任何重新生成插件清单的操作都会把它打回 13.0:flutter pub get / clean / install、 # 改完 pubspec 的自动 pub get。也就是说这不是一次性问题,会反复出现。 # # ── 怎么用 ────────────────────────────────────────────────────────────────── # ./ios/sync_spm_platform.sh # 在 apps/client 下随时手动跑,幂等 # ./ios/sync_spm_platform.sh --check # 只检查不改写,不一致时退出码 1(给 CI 用) # # Xcode 在构建进程启动时就读完 Package.swift,scheme 里的 pre-action 改文件 # 已经晚了(当前 Xcode 还会把 Flutter 写的 13.0 抬成 15.0 再去比插件的 16.0)。 # 所以除了构建前再跑一遍,脚本还会装一个只盯这份生成包的 LaunchAgent: # flutter pub get 一把文件写回 13.0,它就立刻改回工程的 deployment target。 # 标题必须保持 "Run Prepare Flutter Framework Script",Flutter 靠这串判断 # scheme 已迁移;改掉的话下次 flutter pub get 会再插一条、把这里冲乱。 # # ⚠️ 不依赖 flutter 命令:Xcode 的 pre-action 环境 PATH 很干净,调 flutter 经常找不到。 # 这里只做纯文本改写,几毫秒完成。代价是生成包必须已经存在(跑过一次 # flutter pub get 即可),不存在时脚本会明确告诉你先跑什么。 # # ⚠️ 别改 Package.swift 本身来"修好"这个问题——它头上写着 Generated file. Do not edit., # 下次 pub get 就被覆盖,属于假修好。本脚本做的是同一件事,但可重复执行。 # ⚠️ macOS 自带 bash 3.2 不认多字节字符边界:`$VAR,` 会被当成变量名 `VAR,`, # 在 set -u 下报 unbound variable。所以本文件里凡是变量后面紧跟中文标点的, # 一律写成 ${VAR}。 set -euo pipefail IOS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CLIENT_DIR="$(cd "$IOS_DIR/.." && pwd)" PBXPROJ="$IOS_DIR/Runner.xcodeproj/project.pbxproj" MANIFESTS=( "$IOS_DIR/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift" "$IOS_DIR/Flutter/ephemeral/Packages/.packages/FlutterFramework/Package.swift" ) CHECK_ONLY=0 [[ "${1:-}" == "--check" ]] && CHECK_ONLY=1 # flutter pub get 会把生成包写回 13.0。Xcode 读这份文件的时机早于任何构建脚本, # 所以要在写回的那一下就改回来,而不是等下一次点构建。 install_watcher() { [[ $CHECK_ONLY -eq 1 ]] && return 0 local uid dir label plist tmp uid="$(id -u)" dir="$IOS_DIR/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" [[ -d "$dir" ]] || return 0 label="com.yimai.eaimar.sync-spm-platform" plist="$HOME/Library/LaunchAgents/${label}.plist" mkdir -p "$HOME/Library/LaunchAgents" tmp="$(mktemp)" cat > "$tmp" < Label ${label} ProgramArguments ${IOS_DIR}/sync_spm_platform.sh --from-watcher WatchPaths ${dir} ThrottleInterval 1 EOF if [[ -f "$plist" ]] && cmp -s "$tmp" "$plist"; then rm -f "$tmp" else mv "$tmp" "$plist" launchctl bootout "gui/${uid}/${label}" >/dev/null 2>&1 || true launchctl bootstrap "gui/${uid}" "$plist" >/dev/null 2>&1 || true fi # 已经在跑就别再 bootstrap;没加载过则补一次 if ! launchctl print "gui/${uid}/${label}" >/dev/null 2>&1; then launchctl bootstrap "gui/${uid}" "$plist" >/dev/null 2>&1 || true fi } # 监视器回调自己时不要再去装卸监视器,否则写文件会把自己叫醒。 if [[ "${1:-}" != "--from-watcher" ]]; then install_watcher || true fi red() { printf "\033[31m%s\033[0m\n" "$*"; } green() { printf "\033[32m%s\033[0m\n" "$*"; } blue() { printf "\033[34m%s\033[0m\n" "$*"; } # 把 .iOS(.v16) / .iOS("16.0") / 16 统一成可比较的 x.y 形式 normalize() { local v="$1" v="${v#.v}" # .v16 → 16 v="${v//\"/}" # 去引号 [[ "$v" == *.* ]] || v="$v.0" # 16 → 16.0 printf '%s' "$v" } # 版本比较:$1 <= $2 ? le() { [[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" == "$1" ]]; } [[ -f "$PBXPROJ" ]] || { red "✗ 找不到 $PBXPROJ"; exit 1; } # ── 1) 工程能接受的上限 ───────────────────────────────────────────────────── # 生成包的平台版本必须 ≤ 每个链接它的 target,所以取全工程最低的那个 deployment target。 CEILING="$(grep -o 'IPHONEOS_DEPLOYMENT_TARGET = [0-9.]*' "$PBXPROJ" \ | awk '{print $3}' | sed 's/;$//' | sort -V | head -1)" [[ -n "$CEILING" ]] || { red "✗ 没能从 project.pbxproj 读出 IPHONEOS_DEPLOYMENT_TARGET"; exit 1; } CEILING="$(normalize "$CEILING")" # ── 2) 插件要求的下限 ─────────────────────────────────────────────────────── # 扫本地插件的 Package.swift,取声明得最高的那个。 FLOOR="0.0"; FLOOR_OWNER="" while IFS= read -r f; do raw="$(grep -o '\.iOS([^)]*)' "$f" | head -1 | sed 's/^\.iOS(//; s/)$//')" || true [[ -n "$raw" ]] || continue v="$(normalize "$raw")" if ! le "$v" "$FLOOR"; then FLOOR="$v"; FLOOR_OWNER="${f#"$CLIENT_DIR"/}"; fi done < <(find "$CLIENT_DIR/local_plugins" -name Package.swift -not -path '*/.*' 2>/dev/null | sort) blue "工程 deployment target(取最低) = ${CEILING} 本地插件最高要求 = ${FLOOR}${FLOOR_OWNER:+ (${FLOOR_OWNER})}" # ── 3) 插件要求超过工程上限 = 真问题,脚本救不了,直接点名 ─────────────────── if ! le "$FLOOR" "$CEILING"; then red "✗ 插件要求 iOS ${FLOOR},但工程最低 target 只有 ${CEILING} —— 这个脚本改不了。" red " 要么把工程的 IPHONEOS_DEPLOYMENT_TARGET 抬到 ${FLOOR}(Runner + Podfile 都要)," red " 要么把 ${FLOOR_OWNER} 的平台声明降下来。" exit 1 fi # ── 4) 对齐生成包 ─────────────────────────────────────────────────────────── # 插件包必须存在。FlutterFramework 是可选的,在才改。 MANIFEST="${MANIFESTS[0]}" if [[ ! -f "$MANIFEST" ]]; then red "✗ 生成包还不存在:${MANIFEST#"$CLIENT_DIR"/}" red " 先在 apps/client 下跑一次 \`flutter pub get\`(或 \`flutter build ios --config-only\`)再执行本脚本。" exit 1 fi # ── 本地插件之间的相对路径依赖 ──────────────────────────────────────────── # 若干本地插件的 Package.swift 用 `../../..//ios/` 引用兄弟插件 # (agent_service→test、azure_speech→classic_bluetooth、recorder_holder→device_jieli、 # chat_api→swift_sdk_local)。这些路径是按 `local_plugins//ios/` 的真实 # 目录写的,但 Flutter 把插件以符号链接挂在 `ephemeral/Packages/.packages/` 下, # SPM 不解析符号链接,`../../..` 就落到了 `ephemeral/`——于是报 # "the package at 'ephemeral//ios/' cannot be accessed"。 # 这里在 ephemeral/ 下给每个本地插件目录补一个同名链接,让那些相对路径能落地。 # 幂等;`flutter pub get` 不会清掉它们。 ensure_local_plugin_links() { local eph="$IOS_DIR/Flutter/ephemeral" local plugins_dir="$CLIENT_DIR/local_plugins" [ -d "$eph" ] || return 0 for d in "$plugins_dir"/*/; do local name name="$(basename "$d")" [ -e "$eph/$name" ] && continue ln -s "../../../local_plugins/$name" "$eph/$name" done } ensure_local_plugin_links align_one() { local manifest="$1" [[ -f "$manifest" ]] || return 0 local current_raw current current_raw="$(grep -o '\.iOS([^)]*)' "$manifest" | head -1 | sed 's/^\.iOS(//; s/)$//')" current="$(normalize "$current_raw")" local label="${manifest#"$CLIENT_DIR"/}" if [[ "$current" == "$CEILING" ]]; then green "✓ ${label} 已是 iOS ${current}" return 0 fi if [[ $CHECK_ONLY -eq 1 ]]; then red "✗ ${label} 是 iOS ${current},应为 ${CEILING}(跑 ./ios/sync_spm_platform.sh 修)" return 1 fi # 只替换 platforms 段里的第一个 .iOS(...),不碰文件其余内容 /usr/bin/sed -i '' "s/\.iOS($(printf '%s' "$current_raw" | sed 's/[.[\*^$\/]/\\&/g'))/.iOS(\"$CEILING\")/" "$manifest" local new new="$(normalize "$(grep -o '\.iOS([^)]*)' "$manifest" | head -1 | sed 's/^\.iOS(//; s/)$//')")" if [[ "$new" != "$CEILING" ]]; then red "✗ 改写失败 ${label},现在是 ${new}" return 1 fi green "✓ ${label} 平台版本 ${current} → ${CEILING}" } fail=0 for m in "${MANIFESTS[@]}"; do align_one "$m" || fail=1 done exit "$fail"