You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

214 lines
10 KiB

#!/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" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${label}</string>
<key>ProgramArguments</key>
<array>
<string>${IOS_DIR}/sync_spm_platform.sh</string>
<string>--from-watcher</string>
</array>
<key>WatchPaths</key>
<array>
<string>${dir}</string>
</array>
<key>ThrottleInterval</key>
<integer>1</integer>
</dict>
</plist>
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 用 `../../../<plugin>/ios/<plugin>` 引用兄弟插件
# (agent_service→test、azure_speech→classic_bluetooth、recorder_holder→device_jieli、
# chat_api→swift_sdk_local)。这些路径是按 `local_plugins/<name>/ios/<name>` 的真实
# 目录写的,但 Flutter 把插件以符号链接挂在 `ephemeral/Packages/.packages/<name>` 下,
# SPM 不解析符号链接,`../../..` 就落到了 `ephemeral/`——于是报
# "the package at 'ephemeral/<plugin>/ios/<plugin>' 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"