diff --git a/README.md b/README.md index 96aa3bf..dff6e9a 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,10 @@ infisical login ./scripts/bootstrap.sh ``` +For a personal-team iPhone visual preview that does not change repository or +release signing settings, follow +[iOS personal-team device preview](docs/ios-physical-device-preview.md). + The bootstrap script writes the ignored files locally with restrictive permissions. It also verifies the generated client configuration against reviewed SHA-256 digests, so an Infisical value change requires a corresponding diff --git a/docs/ios-physical-device-preview.md b/docs/ios-physical-device-preview.md new file mode 100644 index 0000000..ac6fb92 --- /dev/null +++ b/docs/ios-physical-device-preview.md @@ -0,0 +1,390 @@ +# iOS 个人团队真机预览 + +本文面向需要在个人 iPhone 上检查 Anycast 视觉改动的维护者和自动化代理。目标是在不使用项目所有者签名、不修改当前工作树、不触发发布流程的前提下,构建、安装并验证一个可独立运行的 iOS 预览包。 + +## 范围 + +本流程只用于本地视觉检查: + +- 使用本机已有的 `Apple Development` 证书和个人开发团队。 +- 构建 Flutter `Profile`/AOT 包。 +- 使用个人预览 Bundle ID。 +- 在隔离工作区临时移除主 App 对 Share Extension 的构建依赖。 +- 安装并启动到明确指定的物理 iPhone。 +- 保留 DerivedData 缓存,减少后续编译时间。 + +本流程不执行以下操作: + +- 不使用项目所有者团队 `5TQ9AN87D8`。 +- 不更改仓库中的发布签名、Bundle ID、entitlements 或 Xcode 配置。 +- 不调用发布证书、发布 provisioning profile 或 App Store Connect 密钥。 +- 不运行 `scripts/prepare_ios_signing.sh`、发布流水线或商店上传。 +- 不自动运行 `scripts/bootstrap.sh`。 +- 不创建版本、标签、发布或商店候选。 +- 不保证 Firebase 登录、RevenueCat 商品或 Share Extension 在个人预览 Bundle ID 下可用。 + +## 成功标准 + +只有同时满足以下条件,才算完成真机预览: + +1. 构建日志显示选中的个人 `Apple Development` 证书。 +2. App 的 `TeamIdentifier` 与该证书 subject 中的 `OU` 一致。 +3. App Bundle ID 是个人预览 ID,不是 `com.kindjeff.anycast`。 +4. App 通过嵌套 framework 和主 bundle 的完整签名验证。 +5. `devicectl` 成功安装并启动 App。 +6. Runner 进程持续存在。 +7. 人在 iPhone 或 iPhone Mirroring 中确认首帧已经渲染,不是白屏。 +8. 原始仓库的 `git status` 与运行脚本前一致。 + +进程存在只能证明 App 没有立即退出,不能证明 Flutter 已经绘制首帧。 + +## 快速路径 + +先查找物理设备 UDID: + +```bash +xcrun xcdevice list --timeout 10 +``` + +再执行只读预检: + +```bash +./scripts/ios_personal_device_preview.sh \ + --device \ + --certificate-id \ + --flutter-bin /absolute/path/to/flutter-3.38.3/bin/flutter \ + --dry-run +``` + +预检通过后,构建、安装并启动: + +```bash +./scripts/ios_personal_device_preview.sh \ + --device \ + --certificate-id \ + --flutter-bin /absolute/path/to/flutter-3.38.3/bin/flutter \ + --open-mirroring +``` + +如果 Xcode 尚未为个人预览 Bundle ID 创建 development profile,只有在用户明确授权 Xcode 联系 Apple 后,才添加: + +```bash +--allow-provisioning-updates +``` + +这个选项可能在 Apple Developer 账户中创建或刷新 App ID、设备注册和 provisioning profile。自动化代理不得自行添加。 + +## 身份标识不能互换 + +Apple 开发签名包含多个看起来相似的十位标识。它们的用途不同。 + +```yaml +verified_session_2026_07_30: + certificate_label_suffix: 4Y2CVN4C56 + certificate_subject_uid: D2NF9QKHRQ + development_team_from_subject_ou: QY9ALBH92W + personal_profile_expiration: 2026-08-05T05:54:13Z + project_owner_team_forbidden_in_personal_preview: 5TQ9AN87D8 +``` + +`4Y2CVN4C56` 出现在证书显示名称末尾,用于选择正确的 `Apple Development` 证书。它不是 Xcode 的 `DEVELOPMENT_TEAM`。 + +Xcode 的 `DEVELOPMENT_TEAM` 必须取自证书 subject 的 `OU`。本次验证中,该值是 `QY9ALBH92W`。 + +脚本不会接受手工猜测的 Team ID。它通过证书后缀选择唯一证书,再从证书 `OU` 自动推导 Team ID;如果推导结果是项目所有者团队 `5TQ9AN87D8`,脚本立即退出。 + +可用以下命令人工复核: + +```bash +security find-identity -v -p codesigning +security find-certificate \ + -c 'Apple Development: ()' \ + -p | + openssl x509 -noout -subject -nameopt RFC2253 +``` + +不要把证书 label 后缀、subject `UID`、subject `OU` 和 provisioning profile UUID 当成同一个值。 + +## 自动化执行内容 + +`scripts/ios_personal_device_preview.sh` 按以下顺序执行。 + +1. 验证 macOS、Flutter `3.38.3` 和所需系统命令。 +2. 验证指定 UDID 对应一个当前可用的物理 iPhone。 +3. 用证书 label 后缀选择 identity,并确认个人 Team 下只有一个有效的 `Apple Development` identity。 +4. 从证书 subject `OU` 推导 `DEVELOPMENT_TEAM`。 +5. 拒绝项目所有者团队和正式 Bundle ID。 +6. 验证 `.env`、`GoogleService-Info.plist` 和 `firebase_options.dart` 与仓库审核过的摘要一致,但不打印内容。 +7. 查找匹配个人 Team、预览 Bundle ID、目标设备和 iOS 平台,且尚未过期的 development profile。 +8. 在 `build/ios/personal-device-preview/workspace` 创建隔离副本。 +9. 在隔离副本中执行 `flutter pub get --enforce-lockfile`。 +10. 排除原工作区已有的 Flutter 绝对路径生成文件,并在完整 project 仍保留 Share Extension host 关系时执行 `flutter build ios --config-only --profile --no-codesign`;该命令重新生成隔离路径配置并执行 `pod install`。 +11. 只在隔离 Xcode project 中应用个人 Profile 签名设置。 +12. 只在隔离 project 中移除 Runner 对 Share Extension 的依赖和嵌入阶段。 +13. 构建 `Profile`/AOT Runner。 +14. 扫描 `Runner.app/Frameworks` 第一层 frameworks,补签未签名项,再重新签名主 App;最后用递归严格验证兜底。 +15. 验证 Xcode 实际选择的签名 identity 与用户指定证书完全一致,并核对 Team ID 和 Bundle ID。 +16. 解码 App 实际嵌入的 provisioning profile,重新核对 Team、application identifier、development 权限、目标设备、iOS 平台和有效期。 +17. 安装并启动 App。 +18. 用已安装 App 的精确容器 URL 轮询设备进程,确认该 Runner 没有立即退出。 +19. 确认运行前后的仓库 `git status` 摘要一致。 +20. 删除包含本地配置副本的隔离工作区,保留 DerivedData 和日志缓存。 + +完整构建日志写入: + +```text +build/ios/personal-device-preview/logs/ +``` + +脚本只在失败时打印日志末尾,避免把数十万行 Xcode 输出送入代理上下文。 + +## 本次事故复盘 + +### 把证书后缀当成 Team ID + +用户明确指定 `4Y2CVN4C56`,但 Xcode 的 `DEVELOPMENT_TEAM` 不接受这个值。证书 subject 显示 `OU=QY9ALBH92W`,这才是实际 Team ID。 + +正确动作是通过证书后缀选择 identity,再从 subject `OU` 推导 Team ID。不要人工复制看起来相似的十位字符串。 + +### 误用项目所有者团队 + +项目中发布设置使用 `5TQ9AN87D8`。本地视觉预览没有权限借用该团队,也不应为了让 Bundle ID 或 Firebase 警告消失而切回它。 + +正确动作是使用个人 Team、个人 Bundle ID 和 development profile。自动化必须显式拒绝项目所有者团队。 + +### 直接启动 Flutter Debug 包 + +通过 `devicectl` 直接启动 Debug 包时,Flutter 输出: + +```text +Cannot create a FlutterEngine instance in debug mode without Flutter tooling or Xcode. +``` + +iOS 14 及以上的 Flutter Debug 包需要 Flutter tooling 或 Xcode 调试会话。进程会立即退出,这不是样式代码崩溃。 + +正确动作是: + +- 临时调试时由 Xcode 或 `flutter run` 启动;或 +- 视觉验收默认使用可独立运行的 `Profile`/AOT 包。 + +本文和自动化选择第二种方法。 + +### Xcode 停在 LLDB 符号加载提示 + +本次会话中,Xcode 曾显示: + +```text +Launching “Runner” is taking longer than expected. +LLDB is likely reading from device memory to resolve symbols. +``` + +提示框被其他窗口遮挡时,设备上会出现进程,但调试启动没有完成,用户只看到白屏。 + +点击 Continue 可以继续 Debug 启动,但这仍然不是稳定的视觉验收路径。Profile/AOT 不依赖该 LLDB 启动阶段。 + +### Debug 连接因应用在后台而断开 + +本次会话中,Xcode 控制台随后显示: + +```text +The OS has terminated the Flutter debug connection for being inactive in the background for too long. +There are no errors with your Flutter application. +``` + +设备进程仍可能存在,但 Flutter 调试连接已经失效。再次点击桌面图标不能替代新的工具启动。 + +正确动作是重新由 Xcode/Flutter 启动 Debug,或改用 Profile/AOT。不要把后台断连误判为应用逻辑崩溃。 + +### `flutter run` 触发 CocoaPods host target 错误 + +临时移除 Runner 对 Share Extension 的引用后再执行 `flutter run`,Flutter 会自动调用 `pod install`。CocoaPods 随即报告找不到 Share Extension 的 host target。 + +正确顺序是: + +1. 先在完整 project 中准备或复用 Pods。 +2. 再只在隔离 project 中移除 Share Extension 的预览依赖。 +3. 用 `xcodebuild` 构建主 Runner。 + +脚本按这个顺序执行。 + +### `Podfile.lock` 与 `Pods/Manifest.lock` 不一致 + +本次环境中的 `Podfile.lock` 记录 CocoaPods `1.16.2`,现有 Pods 由 `1.17.0` 生成。仅版本标记不一致也会触发 `[CP] Check Pods Manifest.lock`。 + +复制现有 `Pods.xcodeproj` 也不可取。Flutter plugin 的相对路径会按隔离工作区重新解释,可能错误指向 `build/ios/.pub-cache`。 + +脚本不复制原工作区的 `Generated.xcconfig` 和 `flutter_export_environment.sh`。它在隔离副本中、修改 Xcode target 关系之前执行一次: + +```bash +flutter build ios --config-only --profile --no-codesign +``` + +Flutter 会重新生成指向隔离副本的绝对路径配置并调用 `pod install`。CocoaPods 使用本机缓存,生成与隔离路径一致的 project 和 `Manifest.lock`。脚本随后校验 `FLUTTER_APPLICATION_PATH`,路径仍指向原工作区时立即退出。 + +原始 `Podfile.lock` 和 Pods 不会被改写。 + +### Share Extension 阻止个人预览构建 + +Share Extension 依赖正式 App Group 和单独签名配置。它的 Debug/Profile 编译还可能无法解析 Runner 使用的 Flutter module。 + +视觉预览不需要 Share Extension。正确动作是只在隔离 project 中移除: + +- Runner 的 Share Extension target dependency。 +- Runner 的 Embed Foundation Extensions 阶段。 + +不要修改仓库中的正式扩展配置,也不要删除扩展源代码。 + +### 嵌套 framework 未签名 + +首次 Profile 安装被 iOS 拒绝: + +```text +Failed to verify code signature ... RevenueCat.framework +0xe800801c (No code signature found.) +``` + +主 App 已签名并不代表 `Runner.app/Frameworks` 第一层的每个 framework 都已签名。 + +正确动作是: + +1. 对 `Runner.app/Frameworks/*.framework` 逐个执行严格验证。 +2. 只补签验证失败的 framework。 +3. 重新签名主 App。 +4. 对整个 App 执行 `codesign --verify --deep --strict`。 + +脚本会自动完成并核对最终 identity 与 Team ID。 + +### Firebase Bundle ID 警告不是缺少密钥 + +个人预览 Bundle ID 与审核过的 Firebase iOS 配置不同,因此控制台会提示: + +```text +The project's Bundle ID is inconsistent with ... GoogleService-Info.plist +``` + +本次本机已经存在: + +- `.env` +- `ios/Runner/GoogleService-Info.plist` +- `lib/firebase_options.dart` + +`scripts/bootstrap.sh` 会生成并验证正式 `com.kindjeff.anycast` 配置。它不会为个人预览 Bundle ID 生成一套新的 Firebase 项目,也不会修复这个预期警告。 + +正确动作是验证现有配置摘要,然后接受个人视觉预览中的 Bundle ID 警告。不要为消除警告而改用正式 Bundle ID、项目所有者 Team 或发布密钥。 + +### RevenueCat 商品不可用不阻止视觉检查 + +RevenueCat 商品与正式 App Store Bundle ID 绑定。个人预览可能输出 offerings 或产品加载失败。 + +这不阻止检查颜色、文字、间距和基本导航。不要把个人预览当作订阅购买、恢复购买或商店集成验收。 + +### PID 不能替代画面验证 + +Runner PID 持续存在时,Flutter 仍可能停在白色 launch view 或初始化阶段。 + +正确动作是打开 iPhone Mirroring 或直接查看手机。若系统要求 Mac 登录密码,自动化代理必须把输入交给用户;不得读取、代填或记录密码。 + +## 配置与 `bootstrap` + +自动化只验证本机现有配置,不生成配置。 + +当以下文件缺失或摘要不一致时,脚本会在编译前退出: + +```text +.env +ios/Runner/GoogleService-Info.plist +lib/firebase_options.dart +``` + +只有获得当前对话中的明确授权,并确认使用 Infisical `dev` 环境后,维护者才能人工运行: + +```bash +./scripts/bootstrap.sh dev +``` + +不要因为一次本地构建缺少配置就自动运行 bootstrap。不要使用 `release` 环境完成普通视觉预览。 + +## 失败处理 + +### 没有匹配的 development profile + +先在 Xcode 的个人账号下为预览 Bundle ID 创建 profile,或在明确授权后添加: + +```bash +--allow-provisioning-updates +``` + +如果 Xcode 请求账号登录、密码、双重验证、信任或设备设置,用户必须亲自完成。 + +本次验证的个人 profile 将于 `2026-08-05T05:54:13Z` 到期。到期后预检会把它视为不匹配;不要为了绕过到期检查而关闭验证。 + +### 手机锁定导致启动失败 + +安装可以在手机锁定时完成,但 SpringBoard 会拒绝启动并报告: + +```text +Unable to launch ... because the device was not, or could not be, unlocked. +``` + +解锁手机后,不要重新编译。直接重试: + +```bash +xcrun devicectl device process launch \ + --device \ + --terminate-existing \ + +``` + +### 安装完整性验证失败 + +查看安装日志: + +```text +build/ios/personal-device-preview/logs/devicectl-install.log +``` + +如果错误仍指向嵌套 framework,先确认脚本输出包含完整 App 签名验证成功,再重试。不要关闭设备安全检查。 + +### App 仍然白屏 + +先确认运行的是 Profile 包,而不是 Debug 包: + +```bash +xcrun devicectl device info processes --device +``` + +然后用 Profile console 重新启动并观察初始化日志: + +```bash +xcrun devicectl device process launch \ + --device \ + --terminate-existing \ + --console \ + +``` + +如果日志已经出现 Flutter semantics、页面控制器或网络请求,必须继续做真实画面检查。不要只凭 PID 或 “Launched application” 判断完成。 + +### 需要重新开始 + +DerivedData 缓存位于: + +```text +build/ios/personal-device-preview/ +``` + +它属于本地 ignored build output。只有在确认没有预览脚本正在运行后,才清理这个明确目录。不要删除仓库根目录、用户目录、Xcode 全局 DerivedData 或 provisioning profiles 来解决一次预览失败。 + +## 最终核对 + +每次交付真机预览前,确认: + +- 证书是用户指定的 `Apple Development` identity。 +- Team ID 来自该证书 `OU`,不是证书 label 后缀。 +- Team ID 不是 `5TQ9AN87D8`。 +- Bundle ID 不是 `com.kindjeff.anycast`。 +- 构建模式是 Profile/AOT。 +- App 和所有嵌套 frameworks 通过签名验证。 +- 手机实际首帧可见。 +- `git status --short --branch` 没有出现脚本造成的改动。 +- 没有运行 bootstrap、release signing、tag、upload 或 store 操作。 diff --git a/scripts/ios_personal_device_preview.sh b/scripts/ios_personal_device_preview.sh new file mode 100755 index 0000000..ea8991c --- /dev/null +++ b/scripts/ios_personal_device_preview.sh @@ -0,0 +1,885 @@ +#!/usr/bin/env bash + +set -euo pipefail +set +x + +umask 077 + +readonly EXPECTED_FLUTTER_VERSION="3.38.3" +readonly PROJECT_OWNER_TEAM_ID="5TQ9AN87D8" +readonly CANONICAL_BUNDLE_ID="com.kindjeff.anycast" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +readonly PROJECT_ROOT +readonly CHECKSUM_FILE="${PROJECT_ROOT}/config/infisical-config.sha256" + +device_id="" +certificate_id="" +preview_bundle_id="" +flutter_bin="${FLUTTER_BIN:-}" +allow_provisioning_updates=false +open_mirroring=false +dry_run=false + +usage() { + cat <<'EOF' +Build, install, and launch an isolated Anycast Profile preview on a personal iPhone. + +Usage: + scripts/ios_personal_device_preview.sh \ + --device \ + --certificate-id \ + [--bundle-id ] \ + [--flutter-bin ] \ + [--allow-provisioning-updates] \ + [--open-mirroring] \ + [--dry-run] + +Required: + --device + Physical iPhone UDID reported by `xcrun xcdevice list`. + + --certificate-id + Identifier shown at the end of an Apple Development identity label, + for example the value inside parentheses. This is not DEVELOPMENT_TEAM. + +Options: + --bundle-id + Personal preview bundle ID. Defaults to: + com..anycast.preview + + --flutter-bin + Flutter executable. The project requires Flutter 3.38.3. + + --allow-provisioning-updates + Allow Xcode to contact Apple and create or refresh personal development + provisioning. Omit this when a matching profile already exists. + + --open-mirroring + Open the macOS iPhone Mirroring app after launch. A human must enter any + Mac login password requested by the system. + + --dry-run + Validate the device, certificate, team, configuration, and profile + without copying, building, signing, installing, or launching. +EOF +} + +while (( $# > 0 )); do + case "$1" in + --device) + device_id="${2:-}" + shift 2 + ;; + --certificate-id) + certificate_id="${2:-}" + shift 2 + ;; + --bundle-id) + preview_bundle_id="${2:-}" + shift 2 + ;; + --flutter-bin) + flutter_bin="${2:-}" + shift 2 + ;; + --allow-provisioning-updates) + allow_provisioning_updates=true + shift + ;; + --open-mirroring) + open_mirroring=true + shift + ;; + --dry-run) + dry_run=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "${device_id}" || -z "${certificate_id}" ]]; then + echo "--device and --certificate-id are required" >&2 + usage >&2 + exit 2 +fi + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "A physical iOS preview requires macOS" >&2 + exit 1 +fi + +required_commands=( + codesign + find + git + openssl + perl + plutil + pod + rsync + ruby + security + shasum + unlink + xcodebuild + xcrun +) + +for required_command in "${required_commands[@]}"; do + if ! command -v "${required_command}" >/dev/null 2>&1; then + echo "Required command is missing: ${required_command}" >&2 + exit 1 + fi +done + +if [[ -z "${flutter_bin}" ]]; then + flutter_bin="$(command -v flutter || true)" +fi +if [[ -z "${flutter_bin}" || ! -x "${flutter_bin}" ]]; then + echo "Flutter was not found. Pass --flutter-bin with Flutter 3.38.3." >&2 + exit 1 +fi +flutter_bin="$(cd "$(dirname "${flutter_bin}")" && pwd)/$(basename "${flutter_bin}")" +readonly flutter_bin + +flutter_version="$( + "${flutter_bin}" --version --machine | + ruby -rjson -e 'puts JSON.parse($stdin.read).fetch("frameworkVersion")' +)" +if [[ "${flutter_version}" != "${EXPECTED_FLUTTER_VERSION}" ]]; then + echo "Expected Flutter ${EXPECTED_FLUTTER_VERSION}, found ${flutter_version}" >&2 + exit 1 +fi +unset flutter_version + +TEMP_PARENT="${TMPDIR:-/tmp}" +TEMP_PARENT="${TEMP_PARENT%/}" +readonly TEMP_PARENT +PREFLIGHT_DIR="$(mktemp -d "${TEMP_PARENT}/anycast-ios-preview.XXXXXX")" +readonly PREFLIGHT_DIR +chmod 700 "${PREFLIGHT_DIR}" + +readonly PREVIEW_ROOT="${PROJECT_ROOT}/build/ios/personal-device-preview" +readonly WORKSPACE_ROOT="${PREVIEW_ROOT}/workspace" +readonly LOCK_DIR="${PREVIEW_ROOT}/lock" +workspace_created=false +lock_created=false + +remove_preview_workspace() { + if [[ -L "${WORKSPACE_ROOT}" ]]; then + echo "Refusing to remove symbolic-link preview workspace: ${WORKSPACE_ROOT}" >&2 + return 1 + fi + + case "${WORKSPACE_ROOT}" in + "${PROJECT_ROOT}"/build/ios/personal-device-preview/workspace) + rm -rf -- "${WORKSPACE_ROOT}" + ;; + *) + echo "Refusing to remove unexpected preview workspace: ${WORKSPACE_ROOT}" >&2 + return 1 + ;; + esac +} + +remove_preview_lock() { + if [[ -L "${LOCK_DIR}" ]]; then + echo "Refusing to remove symbolic-link preview lock: ${LOCK_DIR}" >&2 + return 1 + fi + + case "${LOCK_DIR}" in + "${PROJECT_ROOT}"/build/ios/personal-device-preview/lock) + if [[ -f "${LOCK_DIR}/pid" ]]; then + unlink "${LOCK_DIR}/pid" + fi + rmdir "${LOCK_DIR}" + ;; + *) + echo "Refusing to remove unexpected preview lock: ${LOCK_DIR}" >&2 + return 1 + ;; + esac +} + +cleanup() { + local status="$?" + + if [[ "${workspace_created}" == "true" && -e "${WORKSPACE_ROOT}" ]]; then + remove_preview_workspace || true + fi + + if [[ "${lock_created}" == "true" && -d "${LOCK_DIR}" ]]; then + remove_preview_lock >/dev/null 2>&1 || true + fi + + case "${PREFLIGHT_DIR}" in + "${TEMP_PARENT}"/anycast-ios-preview.*) + rm -rf -- "${PREFLIGHT_DIR}" + ;; + esac + + return "${status}" +} +trap cleanup EXIT INT TERM + +device_list_path="${PREFLIGHT_DIR}/devices.json" +xcrun xcdevice list --timeout 10 >"${device_list_path}" +if ! ruby -rjson -e ' + devices = JSON.parse(File.read(ARGV.fetch(0))) + wanted = ARGV.fetch(1) + device = devices.find { |item| item["identifier"] == wanted } + exit 1 unless device + exit 2 unless device["simulator"] == false + exit 3 unless device["available"] == true + exit 0 +' "${device_list_path}" "${device_id}"; then + echo "The requested UDID is not an available physical device: ${device_id}" >&2 + exit 1 +fi + +identity_matches="$( + security find-identity -v -p codesigning | + grep -F "(${certificate_id})" | + grep '"Apple Development:' || true +)" +identity_count="$(grep -c . <<<"${identity_matches}" || true)" +if [[ "${identity_count}" != "1" ]]; then + echo "Expected one Apple Development identity ending in (${certificate_id}); found ${identity_count}" >&2 + exit 1 +fi + +identity_hash="$(awk '{print $2}' <<<"${identity_matches}")" +identity_label="$( + sed -E 's/^[^"]*"([^"]+)".*$/\1/' <<<"${identity_matches}" +)" +if [[ ! "${identity_hash}" =~ ^[0-9A-Fa-f]{40}$ || + "${identity_label}" == *$'\n'* || + "${identity_label}" == *'"'* ]]; then + echo "The selected Apple Development identity could not be parsed safely" >&2 + exit 1 +fi +readonly identity_hash +readonly identity_label +unset identity_matches identity_count + +certificate_subject="$( + security find-certificate -c "${identity_label}" -p | + openssl x509 -noout -subject -nameopt RFC2253 +)" +development_team="$( + sed -E 's/^.*OU=([^,]+).*$/\1/' <<<"${certificate_subject}" +)" +if [[ ! "${development_team}" =~ ^[A-Z0-9]{10}$ ]]; then + echo "Could not derive DEVELOPMENT_TEAM from the certificate OU" >&2 + exit 1 +fi +if [[ "${development_team}" == "${PROJECT_OWNER_TEAM_ID}" ]]; then + echo "Refusing project-owner team ${PROJECT_OWNER_TEAM_ID}; use a personal Apple Development identity" >&2 + exit 1 +fi +readonly development_team +unset certificate_subject + +team_identity_count=0 +while IFS= read -r candidate_line; do + candidate_label="$( + sed -E 's/^[^"]*"([^"]+)".*$/\1/' <<<"${candidate_line}" + )" + candidate_subject="$( + security find-certificate -c "${candidate_label}" -p 2>/dev/null | + openssl x509 -noout -subject -nameopt RFC2253 2>/dev/null || true + )" + candidate_team="$( + sed -E 's/^.*OU=([^,]+).*$/\1/' <<<"${candidate_subject}" + )" + if [[ "${candidate_team}" == "${development_team}" ]]; then + (( team_identity_count += 1 )) + fi +done < <( + security find-identity -v -p codesigning | + grep '"Apple Development:' || true +) + +if [[ "${team_identity_count}" != "1" ]]; then + echo "Expected one valid Apple Development identity for team ${development_team}; found ${team_identity_count}" >&2 + echo "Automatic signing would be ambiguous, so the preview stopped before building." >&2 + exit 1 +fi +unset team_identity_count candidate_line candidate_label candidate_subject candidate_team + +if [[ -z "${preview_bundle_id}" ]]; then + preview_bundle_id="com.$( + tr '[:upper:]' '[:lower:]' <<<"${development_team}" + ).anycast.preview" +fi +if [[ ! "${preview_bundle_id}" =~ ^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$ ]]; then + echo "Invalid preview bundle ID: ${preview_bundle_id}" >&2 + exit 1 +fi +if [[ "${preview_bundle_id}" == "${CANONICAL_BUNDLE_ID}" ]]; then + echo "The personal preview must not use the canonical owner bundle ID" >&2 + exit 1 +fi +readonly preview_bundle_id + +if [[ ! -f "${CHECKSUM_FILE}" ]]; then + echo "Missing reviewed configuration checksums: ${CHECKSUM_FILE}" >&2 + exit 1 +fi + +normalized_digest() { + perl -0777 -pe 's/\n*\z/\n/' "$1" | + shasum -a 256 | + awk '{print $1}' +} + +verify_reviewed_config() { + local repository_path="$1" + local file_path="${PROJECT_ROOT}/${repository_path}" + local expected_digest + local actual_digest + + if [[ ! -f "${file_path}" ]]; then + echo "Missing local app configuration: ${repository_path}" >&2 + echo "This preview script never runs scripts/bootstrap.sh automatically." >&2 + exit 1 + fi + + expected_digest="$( + awk -v repository_path="${repository_path}" \ + '$2 == repository_path { print $1; exit }' \ + "${CHECKSUM_FILE}" + )" + actual_digest="$(normalized_digest "${file_path}")" + + if [[ ! "${expected_digest}" =~ ^[0-9a-f]{64}$ || + "${actual_digest}" != "${expected_digest}" ]]; then + echo "Local app configuration is not the reviewed version: ${repository_path}" >&2 + exit 1 + fi +} + +verify_reviewed_config ".env" +verify_reviewed_config "ios/Runner/GoogleService-Info.plist" +verify_reviewed_config "lib/firebase_options.dart" + +configured_bundle_id="$( + plutil -extract BUNDLE_ID raw -o - \ + "${PROJECT_ROOT}/ios/Runner/GoogleService-Info.plist" +)" +if [[ "${configured_bundle_id}" != "${CANONICAL_BUNDLE_ID}" ]]; then + echo "Unexpected Firebase iOS bundle ID in reviewed configuration" >&2 + exit 1 +fi +unset configured_bundle_id + +profile_found=false +profile_name="" +profile_expiration="" +profile_install_dir="${HOME}/Library/Developer/Xcode/UserData/Provisioning Profiles" +if [[ -d "${profile_install_dir}" ]]; then + while IFS= read -r -d '' profile_path; do + decoded_profile="${PREFLIGHT_DIR}/profile.plist" + if ! security cms -D -i "${profile_path}" \ + >"${decoded_profile}" 2>/dev/null; then + continue + fi + + profile_team="$( + plutil -extract TeamIdentifier.0 raw -o - \ + "${decoded_profile}" 2>/dev/null || true + )" + profile_app_id="$( + plutil -extract Entitlements.application-identifier raw -o - \ + "${decoded_profile}" 2>/dev/null || true + )" + profile_debug="$( + plutil -extract Entitlements.get-task-allow raw -o - \ + "${decoded_profile}" 2>/dev/null || true + )" + candidate_expiration="$( + plutil -extract ExpirationDate raw -o - \ + "${decoded_profile}" 2>/dev/null || true + )" + candidate_devices="$( + plutil -extract ProvisionedDevices json -o - \ + "${decoded_profile}" 2>/dev/null || true + )" + candidate_platforms="$( + plutil -extract Platform json -o - \ + "${decoded_profile}" 2>/dev/null || true + )" + + if [[ "${profile_team}" == "${development_team}" && + "${profile_app_id}" == "${development_team}.${preview_bundle_id}" && + "${profile_debug}" == "true" ]] && + ruby -rjson -e \ + 'begin + exit JSON.parse(ARGV.fetch(0)).include?(ARGV.fetch(1)) ? 0 : 1 + rescue JSON::ParserError + exit 1 + end' \ + "${candidate_devices}" "${device_id}" && + ruby -rjson -e \ + 'begin + exit JSON.parse(ARGV.fetch(0)).include?("iOS") ? 0 : 1 + rescue JSON::ParserError + exit 1 + end' \ + "${candidate_platforms}" && + ruby -rtime -e \ + 'exit Time.parse(ARGV.fetch(0)) > Time.now ? 0 : 1' \ + "${candidate_expiration}"; then + profile_name="$( + plutil -extract Name raw -o - "${decoded_profile}" 2>/dev/null || true + )" + profile_expiration="${candidate_expiration}" + profile_found=true + break + fi + done < <(find "${profile_install_dir}" -type f -print0) +fi + +if [[ "${profile_found}" != "true" && + "${allow_provisioning_updates}" != "true" ]]; then + echo "No personal development profile matches ${preview_bundle_id}." >&2 + echo "Create it in Xcode first, or rerun with --allow-provisioning-updates after explicit approval." >&2 + exit 1 +fi + +echo "Personal iOS preview preflight passed:" +echo " Device UDID: ${device_id}" +echo " Certificate: ${identity_label}" +echo " DEVELOPMENT_TEAM: ${development_team}" +echo " Preview bundle ID: ${preview_bundle_id}" +if [[ "${profile_found}" == "true" ]]; then + echo " Provisioning profile: ${profile_name}" + echo " Profile expires: ${profile_expiration}" + if ruby -rtime -e \ + 'exit Time.parse(ARGV.fetch(0)) - Time.now < 7 * 86_400 ? 0 : 1' \ + "${profile_expiration}"; then + echo " WARNING: provisioning profile expires in less than seven days" + fi +else + echo " Provisioning profile: Xcode may create or refresh it" +fi +echo " Flutter: ${EXPECTED_FLUTTER_VERSION}" + +if [[ "${dry_run}" == "true" ]]; then + echo "Dry run complete. No build, signing, installation, or launch was performed." + exit 0 +fi + +initial_git_status_digest="$( + git -C "${PROJECT_ROOT}" status \ + --porcelain=v1 \ + -z \ + --untracked-files=all | + shasum -a 256 | + awk '{print $1}' +)" +readonly initial_git_status_digest + +mkdir -p "${PREVIEW_ROOT}" +chmod 700 "${PREVIEW_ROOT}" +if [[ -d "${LOCK_DIR}" ]]; then + existing_pid="" + if [[ -f "${LOCK_DIR}/pid" ]]; then + existing_pid="$(<"${LOCK_DIR}/pid")" + fi + if [[ "${existing_pid}" =~ ^[0-9]+$ ]] && + kill -0 "${existing_pid}" >/dev/null 2>&1; then + echo "Another personal-device preview is running with PID ${existing_pid}" >&2 + exit 1 + fi + echo "Removing a stale personal-device preview lock..." + remove_preview_lock +fi +if ! mkdir "${LOCK_DIR}" 2>/dev/null; then + echo "Another personal-device preview may be running: ${LOCK_DIR}" >&2 + exit 1 +fi +lock_created=true +printf '%s\n' "$$" >"${LOCK_DIR}/pid" + +if [[ -e "${WORKSPACE_ROOT}" ]]; then + remove_preview_workspace +fi +mkdir -p "${WORKSPACE_ROOT}" +chmod 700 "${WORKSPACE_ROOT}" +workspace_created=true + +bundle_cache_key="$( + printf '%s' "${preview_bundle_id}" | + shasum -a 256 | + awk '{print substr($1, 1, 12)}' +)" +readonly DERIVED_DATA_PATH="${PREVIEW_ROOT}/DerivedData-${development_team}-${device_id}-${bundle_cache_key}" +readonly LOG_DIR="${PREVIEW_ROOT}/logs" +mkdir -p "${DERIVED_DATA_PATH}" "${LOG_DIR}" +chmod 700 "${DERIVED_DATA_PATH}" "${LOG_DIR}" + +echo "Preparing isolated preview workspace..." +rsync -a \ + --exclude '/.git/' \ + --exclude '/.infisical.json' \ + --exclude '/.dart_tool/' \ + --exclude '/build/' \ + --exclude '/ios/Flutter/Generated.xcconfig' \ + --exclude '/ios/Flutter/flutter_export_environment.sh' \ + --exclude '/ios/Pods/' \ + "${PROJECT_ROOT}/" \ + "${WORKSPACE_ROOT}/" + +dependency_log="${LOG_DIR}/flutter-pub-get.log" +if ! ( + cd "${WORKSPACE_ROOT}" + "${flutter_bin}" pub get --enforce-lockfile +) >"${dependency_log}" 2>&1; then + echo "Flutter dependency resolution failed. Last log lines:" >&2 + tail -80 "${dependency_log}" >&2 + exit 1 +fi + +echo "Generating path-correct iOS configuration and CocoaPods..." +ios_config_log="${LOG_DIR}/flutter-ios-config.log" +if ! ( + cd "${WORKSPACE_ROOT}" + "${flutter_bin}" build ios \ + --config-only \ + --profile \ + --no-codesign +) >"${ios_config_log}" 2>&1; then + echo "Flutter iOS configuration failed. Last log lines:" >&2 + tail -80 "${ios_config_log}" >&2 + exit 1 +fi + +generated_config="${WORKSPACE_ROOT}/ios/Flutter/Generated.xcconfig" +if [[ ! -f "${generated_config}" ]] || + ! grep -Fqx "FLUTTER_APPLICATION_PATH=${WORKSPACE_ROOT}" "${generated_config}"; then + echo "Flutter generated an iOS configuration outside the isolated workspace" >&2 + exit 1 +fi +unset generated_config + +project_file="${WORKSPACE_ROOT}/ios/Runner.xcodeproj/project.pbxproj" +ruby - \ + "${project_file}" \ + "${development_team}" \ + "${preview_bundle_id}" <<'RUBY' +path, team, bundle_id = ARGV +text = File.binread(path) + +def replace_once(text, old_value, new_value, label) + count = text.scan(old_value).length + abort("Expected one #{label}; found #{count}") unless count == 1 + text.sub(old_value, new_value) +end + +text = replace_once( + text, + "\t\t\t\t2EAEC0832C4C104700F151D9 /* StoreKit.framework in Frameworks */,\n", + "", + "Runner StoreKit framework entry" +) +text = replace_once( + text, + "\t\t\t\t2E26E3072C54D10B00211493 /* Embed Foundation Extensions */,\n", + "", + "Runner Share Extension embed phase" +) +text = replace_once( + text, + "\t\t\t\t2E26E3052C54D10B00211493 /* PBXTargetDependency */,\n", + "", + "Runner Share Extension target dependency" +) + +profile_marker = "\t\t249021D4217E4FDB00AE95B9 /* Profile */ = {\n" +profile_start = text.index(profile_marker) +abort("Runner Profile configuration was not found") unless profile_start +profile_end = text.index("\n\t\t};", profile_start) +abort("Runner Profile configuration end was not found") unless profile_end +profile_end += "\n\t\t};".length +profile = text[profile_start...profile_end] + +group_id = "group.#{team.downcase}.anycast.preview" + +profile = replace_once( + profile, + "\t\t\t\tCODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;\n", + "\t\t\t\tCODE_SIGN_ENTITLEMENTS = \"\";\n" \ + "\t\t\t\tCODE_SIGN_IDENTITY = \"Apple Development\";\n" \ + "\t\t\t\t\"CODE_SIGN_IDENTITY[sdk=iphoneos*]\" = \"Apple Development\";\n" \ + "\t\t\t\tCODE_SIGN_STYLE = Automatic;\n", + "Runner Profile signing settings" +) +profile = replace_once( + profile, + "\t\t\t\tCUSTOM_GROUP_ID = group.com.kindjeff.ShareExtention;\n", + "\t\t\t\tCUSTOM_GROUP_ID = #{group_id};\n", + "Runner Profile app-group setting" +) +profile = replace_once( + profile, + "\t\t\t\tDEVELOPMENT_TEAM = 5TQ9AN87D8;\n", + "\t\t\t\tDEVELOPMENT_TEAM = #{team};\n", + "Runner Profile development team" +) +profile = replace_once( + profile, + "\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.kindjeff.anycast;\n", + "\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = #{bundle_id};\n", + "Runner Profile bundle ID" +) +profile = profile.gsub( + /^\s*PROVISIONING_PROFILE_SPECIFIER = .*;\n/, + "" +) + +text[profile_start...profile_end] = profile +File.binwrite(path, text) +RUBY + +build_log="${LOG_DIR}/xcodebuild-profile.log" +build_command=( + xcodebuild + -workspace "${WORKSPACE_ROOT}/ios/Runner.xcworkspace" + -scheme Runner + -configuration Profile + -destination "id=${device_id}" + -derivedDataPath "${DERIVED_DATA_PATH}" + COMPILER_INDEX_STORE_ENABLE=NO +) +if [[ "${allow_provisioning_updates}" == "true" ]]; then + build_command+=(-allowProvisioningUpdates) +fi +build_command+=(build) + +echo "Building the Profile/AOT preview..." +if ! "${build_command[@]}" >"${build_log}" 2>&1; then + echo "Profile build failed. Last log lines:" >&2 + tail -120 "${build_log}" >&2 + exit 1 +fi + +readonly APP_PATH="${DERIVED_DATA_PATH}/Build/Products/Profile-iphoneos/Runner.app" +if [[ ! -d "${APP_PATH}" ]]; then + echo "Profile build succeeded without producing Runner.app" >&2 + exit 1 +fi + +actual_bundle_id="$( + plutil -extract CFBundleIdentifier raw -o - "${APP_PATH}/Info.plist" +)" +if [[ "${actual_bundle_id}" != "${preview_bundle_id}" ]]; then + echo "Built bundle ID does not match the requested preview bundle ID" >&2 + exit 1 +fi +unset actual_bundle_id + +resigned_framework=false +while IFS= read -r -d '' framework_path; do + if ! codesign --verify --strict "${framework_path}" >/dev/null 2>&1; then + echo "Signing nested framework: $(basename "${framework_path}")" + codesign \ + --force \ + --sign "${identity_hash}" \ + --preserve-metadata=identifier,entitlements \ + "${framework_path}" + resigned_framework=true + fi +done < <( + find "${APP_PATH}/Frameworks" \ + -maxdepth 1 \ + -type d \ + -name '*.framework' \ + -print0 +) + +if [[ "${resigned_framework}" == "true" ]]; then + codesign \ + --force \ + --sign "${identity_hash}" \ + --preserve-metadata=identifier,entitlements \ + "${APP_PATH}" +fi + +codesign --verify --deep --strict --verbose=2 "${APP_PATH}" +signature_details="$(codesign -dvvv "${APP_PATH}" 2>&1)" +if ! grep -Fqx "Authority=${identity_label}" <<<"${signature_details}"; then + echo "The built app was not signed by the selected Apple Development identity" >&2 + exit 1 +fi +if ! grep -Fqx "TeamIdentifier=${development_team}" <<<"${signature_details}"; then + echo "The built app was not signed by the derived personal team" >&2 + exit 1 +fi +unset signature_details + +embedded_profile_path="${APP_PATH}/embedded.mobileprovision" +embedded_profile_plist="${PREFLIGHT_DIR}/embedded-profile.plist" +if [[ ! -f "${embedded_profile_path}" ]] || + ! security cms -D -i "${embedded_profile_path}" \ + >"${embedded_profile_plist}" 2>/dev/null; then + echo "The built app does not contain a readable provisioning profile" >&2 + exit 1 +fi + +embedded_profile_team="$( + plutil -extract TeamIdentifier.0 raw -o - \ + "${embedded_profile_plist}" 2>/dev/null || true +)" +embedded_profile_app_id="$( + plutil -extract Entitlements.application-identifier raw -o - \ + "${embedded_profile_plist}" 2>/dev/null || true +)" +embedded_profile_debug="$( + plutil -extract Entitlements.get-task-allow raw -o - \ + "${embedded_profile_plist}" 2>/dev/null || true +)" +embedded_profile_expiration="$( + plutil -extract ExpirationDate raw -o - \ + "${embedded_profile_plist}" 2>/dev/null || true +)" +embedded_profile_devices="$( + plutil -extract ProvisionedDevices json -o - \ + "${embedded_profile_plist}" 2>/dev/null || true +)" +embedded_profile_platforms="$( + plutil -extract Platform json -o - \ + "${embedded_profile_plist}" 2>/dev/null || true +)" + +if [[ "${embedded_profile_team}" != "${development_team}" || + "${embedded_profile_app_id}" != "${development_team}.${preview_bundle_id}" || + "${embedded_profile_debug}" != "true" ]] || + ! ruby -rjson -e ' + begin + exit JSON.parse(ARGV.fetch(0)).include?(ARGV.fetch(1)) ? 0 : 1 + rescue JSON::ParserError + exit 1 + end + ' "${embedded_profile_devices}" "${device_id}" || + ! ruby -rjson -e ' + begin + exit JSON.parse(ARGV.fetch(0)).include?("iOS") ? 0 : 1 + rescue JSON::ParserError + exit 1 + end + ' "${embedded_profile_platforms}" || + ! ruby -rtime -e \ + 'exit Time.parse(ARGV.fetch(0)) > Time.now ? 0 : 1' \ + "${embedded_profile_expiration}"; then + echo "The app's embedded provisioning profile does not match the verified personal preview" >&2 + exit 1 +fi +unset \ + embedded_profile_team \ + embedded_profile_app_id \ + embedded_profile_debug \ + embedded_profile_expiration \ + embedded_profile_devices \ + embedded_profile_platforms + +install_log="${LOG_DIR}/devicectl-install.log" +echo "Installing the preview on the physical iPhone..." +if ! xcrun devicectl device install app \ + --device "${device_id}" \ + "${APP_PATH}" >"${install_log}" 2>&1; then + echo "Device installation failed. Last log lines:" >&2 + tail -80 "${install_log}" >&2 + exit 1 +fi + +launch_log="${LOG_DIR}/devicectl-launch.log" +echo "Launching the standalone Profile preview..." +if ! xcrun devicectl device process launch \ + --device "${device_id}" \ + --terminate-existing \ + "${preview_bundle_id}" >"${launch_log}" 2>&1; then + echo "Device launch failed. Last log lines:" >&2 + tail -80 "${launch_log}" >&2 + exit 1 +fi + +apps_json="${PREFLIGHT_DIR}/apps.json" +xcrun devicectl device info apps \ + --device "${device_id}" \ + --json-output "${apps_json}" >/dev/null +if ! installed_executable="$( + ruby -rjson -e ' + apps = JSON.parse(File.read(ARGV.fetch(0))).dig("result", "apps") || [] + app = apps.find { |item| item["bundleIdentifier"] == ARGV.fetch(1) } + exit 1 unless app + print "#{app.fetch("url")}Runner" + ' "${apps_json}" "${preview_bundle_id}" +)"; then + echo "The installed preview app could not be resolved by bundle ID" >&2 + exit 1 +fi +readonly installed_executable + +process_json="${PREFLIGHT_DIR}/processes.json" +process_running=false +for _ in {1..15}; do + xcrun devicectl device info processes \ + --device "${device_id}" \ + --json-output "${process_json}" >/dev/null + if ruby -rjson -e ' + data = JSON.parse(File.read(ARGV.fetch(0))) + processes = data.dig("result", "runningProcesses") || [] + exit( + processes.any? { |item| + item.fetch("executable", "") == ARGV.fetch(1) + } ? 0 : 1 + ) + ' "${process_json}" "${installed_executable}"; then + process_running=true + break + fi + sleep 2 +done + +if [[ "${process_running}" != "true" ]]; then + echo "Runner did not remain active after launch" >&2 + exit 1 +fi + +final_git_status_digest="$( + git -C "${PROJECT_ROOT}" status \ + --porcelain=v1 \ + -z \ + --untracked-files=all | + shasum -a 256 | + awk '{print $1}' +)" +if [[ "${final_git_status_digest}" != "${initial_git_status_digest}" ]]; then + echo "The repository worktree status changed during the preview run" >&2 + echo "Inspect git status before treating the preview as verified." >&2 + exit 1 +fi +unset final_git_status_digest + +if [[ "${open_mirroring}" == "true" ]]; then + open -b com.apple.ScreenContinuity +fi + +echo "Anycast is installed and running as a standalone Profile preview." +echo "Confirm the actual first frame on the iPhone or in iPhone Mirroring." +echo "A running PID alone does not prove that the UI is not white." +echo "Build log: ${build_log}"