dd721fafc8
原 `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
396 lines
19 KiB
Bash
Executable File
396 lines
19 KiB
Bash
Executable File
#!/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
|