Files
pangolin/scripts/local_test.sh
T
wangjia dd721fafc8 fix(scripts): local_test.sh ipad 改走公司分发证书(ad-hoc),装机前核验签名主体
原 `ipad` 子命令用 `flutter run --release` 装真机,走的是 Xcode 工程的**开发**
签名。Apple 只把 Development 证书签发给团队里的**个人成员**(不存在组织版),
于是装到机上「设置→通用→VPN 与设备管理」里开发者显示成个人姓名,而不是
Yanmei (beijing) Technology Co., Ltd。

改为 ad-hoc 分发(Xcode 15+ 的 method=release-testing)+ 自动签名:
- 新增 write_ios_export_options() 生成 ExportOptions.plist(teamID=BYL4KQHMTN);
- `flutter build ipa --release --export-options-plist=…` 导出时由 Xcode 用
  **Apple Distribution: Yanmei…** 重签;
- 装机前解包 IPA 逐字比对签名主体(IOS_EXPECT_SIGNER),不符即中止 —— 防止
  以后无声退回个人证书;
- 用 devicectl 安装;仅当覆盖安装失败(旧包签名主体不同)才卸载重装,不无条件
  先卸(卸载会清空 app 数据要重新登录);
- 构建失败时提示新设备注册命令:光给 -allowProvisioningUpdates 不够,必须同时
  给 -allowProvisioningDeviceRegistration。

另外两处顺手修:
- 取 IPA 路径原写 `ls … | head -1`,在 set -o pipefail 下 head 提前退出会让 ls
  吃 SIGPIPE、管道返回 141,被 set -e 当失败终止脚本(实测复现,退出码 141)。
  改用数组 glob。签名主体提取的 `codesign | grep -m1` 同一个坑,改成整段捕获
  后用 awk 读完全部输入。
- API_URL 支持 env 覆盖(默认值不变),便于装生产地址的包做验证。

验证:bash -n 通过;shellcheck 无新增告警;
API_URL=https://api.yanmeiai.com bash scripts/local_test.sh ipad 无字天书
端到端 exit=0,日志确认"签名核验通过: Apple Distribution: Yanmei…"并装机成功。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-22 11:59:08 +08:00

396 lines
19 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
#
# local_test.sh — macOS 客户端本地联调一条龙(Developer ID 重签 + 装 + 跑)。
#
# 背景:flutter build 出来的包是 Apple Development 签名;要让 System Extension
# 能加载(且不依赖 SIP/dev mode),需重签成 Developer ID。本脚本封装这套流程。
#
# 子命令:
# build flutter build macos --release(注入联调节点地址)
# sign Developer ID 内向外重签整个 app + PacketTunnel sysext(嵌 DevID profile)
# copy 覆盖安装到 /Applications
# run 直跑 /Applications 版本(绕 Gatekeeper,实时输出日志,并打印测试账号)
# all 依次执行 build → sign → copy → run
#
# ── iOS / iPad 真机(USB 连接;与上面 macOS 流程完全独立)──
# ios-devices 列出已连接的 iOS 物理设备(拿 device id)
# ipad [id|名字] iOS/iPad 真机装机:ad-hoc 导出(**公司**分发证书 Apple
# Distribution: Yanmei…)→ 核验签名主体 → devicectl 装。
# 仅一台 iOS 设备时可省参数;iPhone/iPad 同连时须指定 id 或名字子串。
# 注:不用 `flutter run --release` —— 那走开发签名,是**个人**证书。
#
# 用法: scripts/local_test.sh all | scripts/local_test.sh ipad ...
set -euo pipefail
# ─────────── 配置(按需改)───────────
# 联调控制面。默认指向联调节点;要装生产地址的包用 env 覆盖:
# API_URL=https://api.yanmeiai.com scripts/local_test.sh ipad
API_URL="${API_URL:-http://103.119.13.48:8080}"
SIGN_ID="Developer ID Application: Yanmei (beijing) Technology Co., Ltd (BYL4KQHMTN)"
APP_PROFILE_NAME="Pangolin App DevID" # 主 app 的 Developer ID 描述文件名
SE_PROFILE_NAME="Pangolin PacketTunnel DevID" # PacketTunnel 的描述文件名
TEST_EMAIL="wang880812@gmail.com" # 联调测试账号
TEST_PASSWORD="pangolin2026"
NOTARY_PROFILE="pangolin-notary" # notarytool 凭据(已存入钥匙串)
EXT_NAME="PacketTunnel" # System Extension 可执行名(killswitch 测试用)
VPN_NAME="Pangolin" # NETunnelProviderManager.localizedDescription
IP_SVC="https://api.ipify.org" # 返回纯文本公网 IP
# 注:DevID profile 已含 system-extension.install + NE(-systemextension 变体);
# app/sysext entitlements 与之对齐(见 write_entitlements)。
# ── iOS/iPad 真机分发(ad-hoc,**公司**证书)────────────────────────────────
TEAM_ID="BYL4KQHMTN" # Yanmei (beijing) Technology Co., Ltd
IOS_BUNDLE_ID="com.pangolin.pangolinVpn"
# 期望的签名主体。装机前逐字核对,不符即中止 —— 防止悄悄退回个人开发证书。
IOS_EXPECT_SIGNER="Apple Distribution: Yanmei (beijing) Technology Co., Ltd (${TEAM_ID})"
# ─────────── 路径推导 ───────────
SRC="${BASH_SOURCE[0]}"
DIR="${SRC%/*}"; [ "$DIR" = "$SRC" ] && DIR="."
cd "$DIR/.."; REPO_ROOT="$PWD"
CLIENT="$REPO_ROOT/client"
APP="$CLIENT/build/macos/Build/Products/Release/pangolin_vpn.app"
# sysext bundle 名 = 标识符(PRODUCT_NAME=com.pangolin.pangolin.PacketTunnel,见 CLAUDE.md),
# 不是短名 PacketTunnel.systemextension。
SE="$APP/Contents/Library/SystemExtensions/com.pangolin.pangolin.PacketTunnel.systemextension"
LIBFW="$SE/Contents/Frameworks/Libbox.framework"
PROF_DIR="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
WORK="${TMPDIR:-/tmp}/pangolin_local_test"; mkdir -p "$WORK"
log(){ printf '\033[1;33m== %s ==\033[0m\n' "$*"; }
die(){ printf '\033[1;31m❌ %s\033[0m\n' "$*" >&2; exit 1; }
# 按 Name 在描述文件目录里定位 .provisionprofile(结果写入全局 FOUND)。
FOUND=""
find_profile(){
local want="$1" f
for f in "$PROF_DIR"/*.provisionprofile; do
[ -e "$f" ] || continue
if security cms -D -i "$f" 2>/dev/null | grep -q ">$want<"; then
FOUND="$f"; return 0
fi
done
return 1
}
write_entitlements(){
cat > "$WORK/app.entitlements" <<'PLIST'
<?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>com.apple.application-identifier</key><string>BYL4KQHMTN.com.pangolin.pangolin</string>
<key>com.apple.developer.team-identifier</key><string>BYL4KQHMTN</string>
<key>com.apple.developer.system-extension.install</key><true/>
<key>com.apple.developer.networking.networkextension</key>
<array><string>packet-tunnel-provider-systemextension</string></array>
<key>com.apple.security.app-sandbox</key><false/>
<key>com.apple.security.network.client</key><true/>
<key>com.apple.security.network.server</key><true/>
<key>keychain-access-groups</key>
<array><string>BYL4KQHMTN.com.pangolin.pangolin</string></array>
</dict></plist>
PLIST
cat > "$WORK/sysext.entitlements" <<'PLIST'
<?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>com.apple.application-identifier</key><string>BYL4KQHMTN.com.pangolin.pangolin.PacketTunnel</string>
<key>com.apple.developer.team-identifier</key><string>BYL4KQHMTN</string>
<key>com.apple.developer.networking.networkextension</key>
<array><string>packet-tunnel-provider-systemextension</string></array>
<key>com.apple.security.app-sandbox</key><true/>
<key>com.apple.security.application-groups</key>
<array><string>group.com.pangolin.pangolin</string></array>
</dict></plist>
PLIST
}
cs(){ codesign --force --options runtime --timestamp "$@"; }
cmd_build(){
log "构建 release(API=$API_URL)"
cd "$CLIENT"
flutter build macos --release --dart-define="PANGOLIN_API_URL=$API_URL"
[ -d "$APP" ] || die "构建产物不存在: $APP"
}
cmd_sign(){
[ -d "$APP" ] || die "先 build:找不到 $APP"
log "Developer ID 重签"
write_entitlements
find_profile "$APP_PROFILE_NAME" || die "找不到描述文件: $APP_PROFILE_NAME"
local app_prof="$FOUND"
find_profile "$SE_PROFILE_NAME" || die "找不到描述文件: $SE_PROFILE_NAME"
local se_prof="$FOUND"
echo " app profile : $app_prof"
echo " sysext prof : $se_prof"
xattr -cr "$APP"
cp "$app_prof" "$APP/Contents/embedded.provisionprofile"
cp "$se_prof" "$SE/Contents/embedded.provisionprofile"
# 内向外:sysext → app 各 framework → app 主体。
# libbox 现为「只 Link 不 Embed」静态进 sysext 二进制(见 CLAUDE.md),sysext 内已无
# 独立 Libbox.framework;签 sysext bundle 即覆盖其静态链接的 libbox,无需单独签 framework。
cs --entitlements "$WORK/sysext.entitlements" -s "$SIGN_ID" "$SE"
local item
for item in "$APP/Contents/Frameworks/"*; do
[ -e "$item" ] && cs -s "$SIGN_ID" "$item"
done
cs --entitlements "$WORK/app.entitlements" -s "$SIGN_ID" "$APP"
codesign --verify --deep --strict "$APP" || die "深度校验失败"
echo " ✅ 重签 + 校验通过($SIGN_ID)"
}
cmd_notarize(){
[ -d "$APP" ] || die "先 build/sign:找不到 $APP"
log "公证(notarytool submit --wait,通常 1-5 分钟)"
local zip="$WORK/pangolin_vpn.zip"
rm -f "$zip"
ditto -c -k --keepParent "$APP" "$zip"
xcrun notarytool submit "$zip" --keychain-profile "$NOTARY_PROFILE" --wait
log "staple 票据到 app"
xcrun stapler staple "$APP"
xcrun stapler validate "$APP" && echo " ✅ 已公证 + staple"
}
cmd_copy(){
[ -d "$APP" ] || die "先 build/sign:找不到 $APP"
log "安装到 /Applications"
osascript -e 'quit app "pangolin_vpn"' 2>/dev/null || true
pkill -x pangolin_vpn 2>/dev/null || true
sleep 1
rm -rf /Applications/pangolin_vpn.app
cp -R "$APP" /Applications/
echo " ✅ /Applications/pangolin_vpn.app"
}
cmd_run(){
log "启动 /Applications/pangolin_vpn.app(直跑,日志见下)"
echo ""
echo " 测试账号: $TEST_EMAIL"
echo " 测试密码: $TEST_PASSWORD"
echo ""
exec /Applications/pangolin_vpn.app/Contents/MacOS/pangolin_vpn
}
# ─────────── KillSwitch fail-closed 验证 ───────────
# 原理:killswitch 开 → NE 设了 includeAllNetworks + enforceRoutes(L2,OS 级强制)
# + NEOnDemandRule 常开(L3)。验证点:隧道数据进程(sysext)被杀的瞬间,流量必须
# 被 OS 挡住、真实 IP 不泄漏;随后 on-demand 把隧道自动拉回。
get_ip(){ curl -fsS --max-time "${2:-5}" "$IP_SVC" 2>/dev/null; }
vpn_status(){ scutil --nc status "$VPN_NAME" 2>/dev/null | head -1; }
ext_pid(){ pgrep -x "$EXT_NAME" 2>/dev/null || pgrep -f "$EXT_NAME.systemextension" 2>/dev/null || true; }
# 第 1 步:VPN 断开下记录「真实公网 IP」基线(后续用来判断是否泄漏)。
cmd_ks_baseline(){
log "KillSwitch 基线:记录真实公网 IP(请确保此刻 VPN 已断开)"
local real; real="$(get_ip real 6)"
[ -n "$real" ] || die "拿不到公网 IP(断网?)。请连上普通网络、断开 VPN 再跑。"
echo "$real" > "$WORK/ks_real_ip"
echo " 真实公网 IP = $real(已存 $WORK/ks_real_ip)"
echo " 默认路由接口:"; route -n get default 2>/dev/null | grep -E 'interface:' || true
}
# 第 2 步:VPN 连接(killswitch 开)下做漏测 —— 杀扩展进程,在重连窗口内狂探 IP。
cmd_ks_test(){
[ -f "$WORK/ks_real_ip" ] || die "先跑 ks-baseline 记录真实 IP。"
local real; real="$(cat "$WORK/ks_real_ip")"
log "KillSwitch 漏测(真实 IP=$real)"
echo " 当前 VPN 状态: $(vpn_status)"
vpn_status | grep -qi Connected || die "VPN 未连接。请在 app 内连接(killswitch 保持开)后再跑。"
local vpnip; vpnip="$(get_ip vpn 6)"
echo " 连接中出口 IP = ${vpnip:-<取不到>}"
[ -n "$vpnip" ] && [ "$vpnip" = "$real" ] && die "出口 IP 等于真实 IP —— VPN 没真正接管,测试无意义。"
local pid; pid="$(ext_pid)"
[ -n "$pid" ] || die "找不到扩展进程 $EXT_NAME(它以 root 运行,确认隧道在跑)"
echo " 扩展进程 PID = $pid,准备 sudo 杀掉模拟崩溃…(可能要输密码)"
sudo kill -9 $pid 2>/dev/null || sudo pkill -x "$EXT_NAME" 2>/dev/null || true
# 杀掉后立刻在重连窗口内反复探测:任何一次拿到「真实 IP」即判定泄漏。
log "重连窗口漏测(8× ~1s)"
local leaked=0 reachable=0 i ip
for i in $(seq 1 8); do
ip="$(get_ip probe 1)"
if [ -z "$ip" ]; then
echo " #$i 流量被阻断(无响应)✓ fail-closed"
elif [ "$ip" = "$real" ]; then
echo " #$i ❌ 泄漏!拿到真实 IP=$ip"
leaked=1
else
echo " #$i 仍走隧道 IP=$ip(on-demand 已拉回)"
reachable=1
fi
done
echo ""
log "on-demand 自愈检查(等 5s)"
sleep 5
echo " VPN 状态: $(vpn_status)"
echo " 此刻出口 IP = $(get_ip after 6)(应为隧道 IP、且 != $real)"
echo ""
if [ "$leaked" = 1 ]; then
die "FAIL —— 检测到真实 IP 泄漏,fail-closed 未生效(检查 includeAllNetworks/enforceRoutes)。"
fi
printf '\033[1;32m✅ PASS —— 扩展被杀期间真实 IP 未泄漏(fail-closed 生效)。\033[0m\n'
echo " 补充手测:在 app 内点「断开」→ 跑 ks-status,应稳定 Disconnected 不被 on-demand 拉回(验 stop() 处理)。"
}
# 辅助:看 NE 是否真把 killswitch 配置写进去了(读统一日志我加的打点)。
cmd_ks_status(){
log "VPN 状态"; vpn_status
echo ""; log "最近 5 分钟 killswitch 相关日志(app + 扩展打点)"
log show --last 5m --style compact \
--predicate 'eventMessage CONTAINS[c] "killSwitch" OR eventMessage CONTAINS "includeAllNetworks"' \
2>/dev/null | tail -20 || echo " (无,确认已连接过一次)"
}
# ─────────── iOS / iPad 真机安装 ───────────
# 与 macOS 流程独立:iOS 用 Network Extension(非 System Extension),不需要 Developer ID
# 重签 / 公证;flutter build ios --release 由 Xcode 工程配置签名(Team=BYL4KQHMTN,自动
# 签名会把已连接设备注册进描述文件),再 flutter install 走 USB 直接装。
# 列出已连接的物理 iOS 设备(输出 "id<TAB>name" 每行一台;模拟器/无线设备排除)。
list_ios_devices(){
flutter devices --machine 2>/dev/null | python3 -c '
import sys, json
try:
devs = json.load(sys.stdin)
except Exception:
devs = []
for d in devs:
if str(d.get("targetPlatform", "")).startswith("ios") and not d.get("emulator", False):
print((d.get("id", "") or "") + "\t" + (d.get("name", "") or ""))
'
}
cmd_ios_devices(){
log "已连接的 iOS 物理设备"
local list; list="$(list_ios_devices)"
[ -n "$list" ] || die "没检测到 iOS 设备。用数据线连上 iPad、解锁并在弹窗点「信任此电脑」后重试。"
printf '%s\n' "$list" | while IFS=$'\t' read -r id name; do printf ' %-42s %s\n' "$id" "$name"; done
}
# 解析目标设备到全局 DEVICE_ID:优先用传入的 id/名字子串;否则仅一台 iOS 设备时自动选它,
# 多台(如 iPhone+iPad 同连)则列出并要求显式指定,绝不瞎猜。
resolve_ios_device(){
local want="${1:-}" list
list="$(list_ios_devices)"
[ -n "$list" ] || die "没检测到 iOS 设备。用数据线连上 iPad、解锁并在弹窗点「信任此电脑」后重试。"
if [ -n "$want" ]; then
DEVICE_ID="$(printf '%s\n' "$list" | grep -iF "$want" | head -1 | cut -f1)"
[ -n "$DEVICE_ID" ] || die "找不到匹配「$want」的 iOS 设备。先跑 ios-devices 看可用列表。"
return 0
fi
local n; n="$(printf '%s\n' "$list" | grep -c . || true)"
if [ "$n" = 1 ]; then
DEVICE_ID="$(printf '%s\n' "$list" | head -1 | cut -f1)"
else
printf '%s\n' "$list" | while IFS=$'\t' read -r id name; do printf ' %-42s %s\n' "$id" "$name" >&2; done
die "检测到多台 iOS 设备(如上)。请指定:scripts/local_test.sh ipad <设备id或名字子串>"
fi
}
# ad-hoc 导出选项。**不要**改回 development/automatic:
# Apple 只把 Development 证书签发给团队里的**个人成员**(没有组织版),走开发签名
# 装机后 iPad「设置→通用→VPN 与设备管理」里开发者会显示成个人姓名。要显示公司名
# 必须用分发证书,而能装到自有真机的分发方式就是 ad-hoc(Xcode 15+ 叫 release-testing)。
write_ios_export_options(){
cat > "$WORK/ExportOptions-adhoc.plist" <<PLIST
<?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>method</key><string>release-testing</string>
<key>teamID</key><string>${TEAM_ID}</string>
<key>signingStyle</key><string>automatic</string>
<key>stripSwiftSymbols</key><true/>
<key>uploadSymbols</key><false/>
<key>compileBitcode</key><false/>
</dict>
</plist>
PLIST
}
cmd_ipad(){
command -v flutter >/dev/null || die "找不到 flutter。"
local DEVICE_ID=""
resolve_ios_device "${1:-}"
write_ios_export_options
cd "$CLIENT"
echo ""
echo " 目标设备 : $DEVICE_ID"
echo " 签名主体 : $IOS_EXPECT_SIGNER"
echo " 联调 API : $API_URL"
echo " 测试账号 : $TEST_EMAIL / $TEST_PASSWORD"
echo " ad-hoc 分发签名无需在「VPN 与设备管理」信任;首次启动要联网让 iOS 校验证书。"
echo " VPN 配置在系统弹窗点「允许」。"
echo ""
log "flutter build ipa --release(ad-hoc / 公司分发证书)"
# 新设备第一次装会因描述文件不含其 UDID 失败(0xe8008012)。自动签名要真正去
# **注册设备**,光给 -allowProvisioningUpdates 不够,必须同时给
# -allowProvisioningDeviceRegistration —— 由 Xcode 在 archive 阶段完成。
flutter build ipa --release \
--export-options-plist="$WORK/ExportOptions-adhoc.plist" \
--dart-define="PANGOLIN_API_URL=$API_URL" \
|| die "IPA 构建失败。若报设备未注册,跑一次:
cd $CLIENT/ios && xcodebuild -workspace Runner.xcworkspace -scheme Runner \\
-configuration Release -destination \"id=$DEVICE_ID\" \\
-allowProvisioningUpdates -allowProvisioningDeviceRegistration build"
# 用 glob 取,不要 `ls | head` —— set -o pipefail 下 head 先退出会让 ls 吃 SIGPIPE,
# 管道返回 141,再被 set -e 当失败直接终止脚本(实测踩过)。
local IPA IPAS=()
IPAS=("$CLIENT"/build/ios/ipa/*.ipa)
IPA="${IPAS[0]}"
[ -f "$IPA" ] || die "没找到 IPA(build/ios/ipa/*.ipa)"
# ── 装机前核验签名主体,防止无声退回个人开发证书 ──
local VDIR="$WORK/ipa_verify"; rm -rf "$VDIR"; mkdir -p "$VDIR"
( cd "$VDIR" && unzip -q "$IPA" ) || die "IPA 解包失败。"
# 同理避开提前退出的管道消费者(grep -m1 / head):先整段捕获,再在内存里取第一条
# Authority(叶子证书)。awk 读完全部输入,不会给上游制造 SIGPIPE。
local AUTH CS_OUT
CS_OUT="$(codesign -dv --verbose=4 "$VDIR/Payload/Runner.app" 2>&1 || true)"
AUTH="$(printf '%s\n' "$CS_OUT" | awk -F'Authority=' '/^Authority=/ && !seen {print $2; seen=1}')"
[ "$AUTH" = "$IOS_EXPECT_SIGNER" ] \
|| die "签名主体不符,拒绝安装。
期望: $IOS_EXPECT_SIGNER
实际: ${AUTH:-<空>}"
log "签名核验通过:$AUTH"
log "装到设备 $DEVICE_ID"
# 同 bundle id 的旧包若是**别的签名主体**(如早先的 Apple Development 个人证书),
# iOS 拒绝覆盖安装。仅在这种情况下才卸载重装 —— 卸载会清空 app 数据(需重新登录),
# 所以不无条件先卸。
if ! xcrun devicectl device install app --device "$DEVICE_ID" "$IPA"; then
log "覆盖安装失败(多半是旧包签名主体不同),卸载旧包后重装 —— app 数据会清空,需重新登录"
xcrun devicectl device uninstall app --device "$DEVICE_ID" "$IOS_BUNDLE_ID" || true
xcrun devicectl device install app --device "$DEVICE_ID" "$IPA" \
|| die "安装失败。"
fi
log "已安装 $IOS_BUNDLE_ID$DEVICE_ID;在设备上点开「穿山甲」即可(首次需联网校验证书)"
}
case "${1:-}" in
build) cmd_build ;;
sign) cmd_sign ;; # 旧:手动 Developer ID 重签(现已由 Xcode 工程配置直接签,通常不需要)
notarize) cmd_notarize ;;
copy) cmd_copy ;;
run) cmd_run ;;
# Xcode 工程已配 Developer ID 手动签名(Release 配置),build 即出 DevID 包,无需 sign。
all) cmd_build; cmd_notarize; cmd_copy; cmd_run ;;
ks-baseline) cmd_ks_baseline ;; # ① VPN 断开下记录真实公网 IP
ks-test) cmd_ks_test ;; # ② VPN 连接(killswitch 开)下杀扩展做漏测
ks-status) cmd_ks_status ;; # 看 VPN 状态 + killswitch 配置打点日志
ios-devices) cmd_ios_devices ;; # 列出已连接的 iOS 物理设备(拿 device id)
ipad|ios) cmd_ipad "${2:-}" ;; # 构建 iOS release 并装到 iPad(可传设备 id/名字子串)
*) echo "用法: $0 {all|build|sign|notarize|copy|run|ks-baseline|ks-test|ks-status|ios-devices|ipad [id]}"; exit 2 ;;
esac