#!/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(推荐,省得每次记着手动跑): # Product → Scheme → Edit Scheme → Build → Pre-actions → + New Run Script Action # Provide build settings from: Runner # 脚本内容: "$SRCROOT/sync_spm_platform.sh" # # ⚠️ 不依赖 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" MANIFEST="$IOS_DIR/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift" CHECK_ONLY=0 [[ "${1:-}" == "--check" ]] && CHECK_ONLY=1 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) 对齐生成包 ─────────────────────────────────────────────────────────── 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 CURRENT_RAW="$(grep -o '\.iOS([^)]*)' "$MANIFEST" | head -1 | sed 's/^\.iOS(//; s/)$//')" CURRENT="$(normalize "$CURRENT_RAW")" if [[ "$CURRENT" == "$CEILING" ]]; then green "✓ 生成包已是 iOS ${CURRENT},无需改动" exit 0 fi if [[ $CHECK_ONLY -eq 1 ]]; then red "✗ 生成包是 iOS ${CURRENT},应为 ${CEILING}(跑 ./ios/sync_spm_platform.sh 修)" exit 1 fi # 只替换 platforms 段里的第一个 .iOS(...),不碰文件其余内容 /usr/bin/sed -i '' "s/\.iOS($(printf '%s' "$CURRENT_RAW" | sed 's/[.[\*^$\/]/\\&/g'))/.iOS(\"$CEILING\")/" "$MANIFEST" NEW="$(normalize "$(grep -o '\.iOS([^)]*)' "$MANIFEST" | head -1 | sed 's/^\.iOS(//; s/)$//')")" if [[ "$NEW" != "$CEILING" ]]; then red "✗ 改写失败,现在是 ${NEW}"; exit 1 fi green "✓ 生成包平台版本 ${CURRENT} → ${CEILING},可以回 Xcode 构建了"