From b32bedc4b54687ebd5c318f935804951e66ab22a Mon Sep 17 00:00:00 2001 From: Nic Glazkov <82780940+nicglazkov@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:04:20 -0500 Subject: [PATCH] feat(ios): Xcode host app and TestFlight refresh pipeline The iOS app now builds and runs: a SwiftUI host generated with xcodegen wraps the shared ComposeApp framework, with the CMP-required Info.plist keys (CADisableMinimumFrameDurationOnPhone, all four orientations, ITSAppUsesNonExemptEncryption=false) and the Summit Pin icon rendered for the App Store asset catalog. Release signing is manual: Apple Distribution certificate plus an App Store profile created through the App Store Connect API. A scheduled workflow re-archives and re-uploads to TestFlight when the newest build has less than 35 days of life left. Also fixes the iOS compile: kotlinx-coroutines pinned to 1.10.2 because 1.11.0 klibs carry a Kotlin 2.2.20 ABI that Kotlin 2.1.21 cannot consume, and the PlatformMap iOS actual gains the focus and layers parameters the common expect grew. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ljo18m9Wka5Shs5b1AtPYA --- .github/workflows/testflight.yml | 149 ++++++++++++++++++ .gitignore | 2 + .../glazkov/chaincheck/ui/MapScreen.ios.kt | 2 + gradle/libs.versions.toml | 4 +- .../AppIcon.appiconset/AppIcon-1024.png | Bin 0 -> 18275 bytes .../AppIcon.appiconset/Contents.json | 14 ++ .../ChainCheck/Assets.xcassets/Contents.json | 6 + iosApp/ChainCheck/ContentView.swift | 15 ++ iosApp/ChainCheck/Info.plist | 46 ++++++ iosApp/ChainCheck/iOSApp.swift | 10 ++ iosApp/README.md | 21 ++- iosApp/project.yml | 60 +++++++ scripts/testflight.py | 111 +++++++++++++ 13 files changed, 438 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/testflight.yml create mode 100644 iosApp/ChainCheck/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png create mode 100644 iosApp/ChainCheck/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 iosApp/ChainCheck/Assets.xcassets/Contents.json create mode 100644 iosApp/ChainCheck/ContentView.swift create mode 100644 iosApp/ChainCheck/Info.plist create mode 100644 iosApp/ChainCheck/iOSApp.swift create mode 100644 iosApp/project.yml create mode 100644 scripts/testflight.py diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml new file mode 100644 index 0000000..1183bbf --- /dev/null +++ b/.github/workflows/testflight.yml @@ -0,0 +1,149 @@ +name: TestFlight refresh + +# A TestFlight build expires after 90 days. This workflow runs twice a month +# and uploads a new build only when the newest build has less than 35 days of +# life left. The public TestFlight link then keeps working without manual work. +on: + schedule: + - cron: "0 15 2,16 * *" + workflow_dispatch: + inputs: + force: + description: "Upload even when the newest build is fresh" + type: boolean + default: false + +permissions: + contents: read + +jobs: + refresh: + runs-on: macos-26 + steps: + - uses: actions/checkout@v4 + + - name: Install the API key + env: + ASC_P8_BASE64: ${{ secrets.ASC_P8_BASE64 }} + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + run: | + mkdir -p ~/.appstoreconnect/private_keys + echo "$ASC_P8_BASE64" | base64 -d > ~/.appstoreconnect/private_keys/AuthKey_$ASC_KEY_ID.p8 + pip3 install --quiet --break-system-packages pyjwt cryptography + + - name: Stop when the newest build is fresh + if: ${{ !inputs.force }} + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_APP_ID: ${{ secrets.ASC_APP_ID }} + run: | + set +e + python3 scripts/testflight.py check-age + code=$? + set -e + if [ "$code" = "3" ]; then + echo "SKIP=true" >> "$GITHUB_ENV" + elif [ "$code" != "0" ]; then + exit "$code" + fi + + - name: Set up JDK 17 + if: env.SKIP != 'true' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Set up Gradle + if: env.SKIP != 'true' + uses: gradle/actions/setup-gradle@v4 + + - name: Select the newest Xcode 26 + if: env.SKIP != 'true' + run: | + sudo xcode-select -s "$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)/Contents/Developer" + xcodebuild -version + + - name: Install the signing identity and the profile + if: env.SKIP != 'true' + env: + IOS_DIST_KEY_BASE64: ${{ secrets.IOS_DIST_KEY_BASE64 }} + IOS_DIST_CERT_BASE64: ${{ secrets.IOS_DIST_CERT_BASE64 }} + IOS_PROFILE_APP_BASE64: ${{ secrets.IOS_PROFILE_APP_BASE64 }} + run: | + KC=$RUNNER_TEMP/build.keychain-db + KCPASS=$(uuidgen) + security create-keychain -p "$KCPASS" "$KC" + security set-keychain-settings -lut 21600 "$KC" + security unlock-keychain -p "$KCPASS" "$KC" + security list-keychains -d user -s "$KC" login.keychain-db + echo "$IOS_DIST_KEY_BASE64" | base64 -d > "$RUNNER_TEMP/dist.key" + echo "$IOS_DIST_CERT_BASE64" | base64 -d > "$RUNNER_TEMP/dist.cer" + security import "$RUNNER_TEMP/dist.key" -k "$KC" -T /usr/bin/codesign -A + security import "$RUNNER_TEMP/dist.cer" -k "$KC" -T /usr/bin/codesign -A + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KCPASS" "$KC" > /dev/null + mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" + echo "$IOS_PROFILE_APP_BASE64" | base64 -d > "$RUNNER_TEMP/app.mobileprovision" + UUID=$(security cms -D -i "$RUNNER_TEMP/app.mobileprovision" | plutil -extract UUID raw -o - -) + cp "$RUNNER_TEMP/app.mobileprovision" "$HOME/Library/MobileDevice/Provisioning Profiles/$UUID.mobileprovision" + security find-identity -p codesigning "$KC" + + - name: Make the Xcode project + if: env.SKIP != 'true' + run: | + brew install xcodegen + cd iosApp && xcodegen generate + + - name: Archive + if: env.SKIP != 'true' + run: | + cd iosApp + # The build number comes from the date, so each run makes a higher one. + BUILD_NUMBER=$(date -u +%Y%m%d%H%M) + xcodebuild -project ChainCheck.xcodeproj -scheme ChainCheck \ + -destination "generic/platform=iOS" \ + -archivePath build/ChainCheck.xcarchive archive \ + CURRENT_PROJECT_VERSION="$BUILD_NUMBER" + + - name: Upload to App Store Connect + if: env.SKIP != 'true' + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + run: | + cd iosApp + cat > build/ExportOptions.plist <<'PLIST' + + + + + methodapp-store-connect + destinationupload + signingStylemanual + teamIDM7D6YHVDNK + signingCertificateApple Distribution + provisioningProfiles + + com.glazkov.chaincheckChainCheck AppStore + + uploadSymbols + + + PLIST + xcodebuild -exportArchive \ + -archivePath build/ChainCheck.xcarchive \ + -exportOptionsPlist build/ExportOptions.plist \ + -exportPath build/export \ + -authenticationKeyPath ~/.appstoreconnect/private_keys/AuthKey_$ASC_KEY_ID.p8 \ + -authenticationKeyID "$ASC_KEY_ID" \ + -authenticationKeyIssuerID "$ASC_ISSUER_ID" + + - name: Publish to the public group + if: env.SKIP != 'true' + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_APP_ID: ${{ secrets.ASC_APP_ID }} + ASC_GROUP_ID: ${{ secrets.ASC_GROUP_ID }} + run: python3 scripts/testflight.py finish diff --git a/.gitignore b/.gitignore index 963dc59..c47f8d5 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,8 @@ DerivedData/ build/ios/ # Signing/config that may carry a team id or key; keep local. iosApp/Configuration/Config.xcconfig +# The Xcode project is generated from iosApp/project.yml with xcodegen. +iosApp/ChainCheck.xcodeproj/ # CocoaPods (only if the Mac setup uses Pods instead of SPM). Pods/ *.xcworkspace/xcuserdata/ diff --git a/composeApp/src/iosMain/kotlin/com/glazkov/chaincheck/ui/MapScreen.ios.kt b/composeApp/src/iosMain/kotlin/com/glazkov/chaincheck/ui/MapScreen.ios.kt index 7014c1c..285e767 100644 --- a/composeApp/src/iosMain/kotlin/com/glazkov/chaincheck/ui/MapScreen.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/glazkov/chaincheck/ui/MapScreen.ios.kt @@ -15,6 +15,8 @@ import platform.UIKit.UIApplication @Composable actual fun PlatformMap( data: MapData, + focus: MapFocus?, + layers: MapLayers, onWebcamTap: (MapWebcam) -> Unit, onNavigateTo: (lat: Double, lon: Double, label: String) -> Unit, modifier: Modifier, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a5dc76c..a13e014 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,9 @@ kotlin = "2.1.21" composeMultiplatform = "1.8.2" androidx-activity = "1.10.1" androidx-fragment = "1.8.6" -coroutines = "1.11.0" +# 1.11.0 is compiled with Kotlin 2.2.20 and its iOS klibs are ABI-incompatible +# with Kotlin 2.1.21; stay on 1.10.2 until the Kotlin/CMP pair moves to 2.2.x. +coroutines = "1.10.2" serialization = "1.8.1" datetime = "0.6.2" ktor = "3.1.3" diff --git a/iosApp/ChainCheck/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/iosApp/ChainCheck/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..1d1d6ffbdfaa585806af1b479c88d1ff63c64810 GIT binary patch literal 18275 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 bhlLJ%EIy`#D6R@eUr{Ggu% literal 0 HcmV?d00001 diff --git a/iosApp/ChainCheck/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/ChainCheck/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..f22e10c --- /dev/null +++ b/iosApp/ChainCheck/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/ChainCheck/Assets.xcassets/Contents.json b/iosApp/ChainCheck/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iosApp/ChainCheck/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/ChainCheck/ContentView.swift b/iosApp/ChainCheck/ContentView.swift new file mode 100644 index 0000000..721e151 --- /dev/null +++ b/iosApp/ChainCheck/ContentView.swift @@ -0,0 +1,15 @@ +import SwiftUI +import ComposeApp + +struct ComposeView: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> UIViewController { + // MainViewController() is a top-level Kotlin function; Kotlin/Native + // exposes it on the file-name class MainViewControllerKt. + MainViewControllerKt.MainViewController() + } + func updateUIViewController(_ vc: UIViewController, context: Context) {} +} + +struct ContentView: View { + var body: some View { ComposeView().ignoresSafeArea() } +} diff --git a/iosApp/ChainCheck/Info.plist b/iosApp/ChainCheck/Info.plist new file mode 100644 index 0000000..e0ff51d --- /dev/null +++ b/iosApp/ChainCheck/Info.plist @@ -0,0 +1,46 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ChainCheck + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + CADisableMinimumFrameDurationOnPhone + + ITSAppUsesNonExemptEncryption + + LSRequiresIPhoneOS + + NSLocationWhenInUseUsageDescription + ChainCheck uses your location to show nearby chain controls and cameras on the map. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/iosApp/ChainCheck/iOSApp.swift b/iosApp/ChainCheck/iOSApp.swift new file mode 100644 index 0000000..9480fad --- /dev/null +++ b/iosApp/ChainCheck/iOSApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct iOSApp: App { + var body: some Scene { + WindowGroup { + ContentView().ignoresSafeArea(.all) + } + } +} diff --git a/iosApp/README.md b/iosApp/README.md index 63d6f50..c3f5046 100644 --- a/iosApp/README.md +++ b/iosApp/README.md @@ -20,7 +20,7 @@ declarations have iOS `actual` implementations in `composeApp/src/iosMain/`. | Map view | Placeholder text | `iosMain/.../ui/MapScreen.ios.kt` | | Push token (FCM) | Stub returns `null` | `iosMain/.../push/PushToken.ios.kt` | | App Check token | Stub returns `null` | `iosMain/.../data/AppCheck.ios.kt` | -| Xcode project | Not created yet | this directory | +| Xcode project | Done, generated with xcodegen | `project.yml` | What this means: once the Xcode project embeds the framework, the app launches and Home, Routes, Resorts, Trip brief, and Alerts all work against the live @@ -39,6 +39,25 @@ enforcement is off (monitoring only); wire it when enforcement flips in October. `composeApp/src/commonMain/.../data/Api.kt` and is public. No per-platform backend work is needed; the JSON API is client agnostic. +## Build and release + +The Xcode project is generated, not committed. To build locally: + +```bash +brew install xcodegen # once +cd iosApp && xcodegen generate +xcodebuild -project ChainCheck.xcodeproj -scheme ChainCheck \ + -destination "generic/platform=iOS Simulator" build +``` + +Release signing is manual: an Apple Distribution certificate plus the +"ChainCheck AppStore" provisioning profile, both created through the App Store +Connect API. The scheduled workflow `.github/workflows/testflight.yml` +re-archives and re-uploads a TestFlight build when the newest one has less +than 35 days of life left, using `scripts/testflight.py` for the API steps. +Secrets live in the GitHub repo settings (ASC key, dist cert and key, profile, +all base64). + ## Step 1: create the Xcode project On the Mac, create a SwiftUI app in this `iosApp/` directory (Xcode: New diff --git a/iosApp/project.yml b/iosApp/project.yml new file mode 100644 index 0000000..20ef09e --- /dev/null +++ b/iosApp/project.yml @@ -0,0 +1,60 @@ +# The Xcode project definition. Regenerate the project with: xcodegen generate +name: ChainCheck +options: + bundleIdPrefix: com.glazkov + deploymentTarget: + iOS: "16.0" + createIntermediateGroups: true + +settings: + base: + DEVELOPMENT_TEAM: M7D6YHVDNK + SWIFT_VERSION: "5.9" + IPHONEOS_DEPLOYMENT_TARGET: "16.0" + TARGETED_DEVICE_FAMILY: "1" + +targets: + ChainCheck: + type: application + platform: iOS + sources: + - path: ChainCheck + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.glazkov.chaincheck + INFOPLIST_FILE: ChainCheck/Info.plist + MARKETING_VERSION: "0.1.2" + CURRENT_PROJECT_VERSION: "1" + FRAMEWORK_SEARCH_PATHS: + - "$(inherited)" + - "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)" + OTHER_LDFLAGS: + - "$(inherited)" + - "-framework" + - "ComposeApp" + CODE_SIGN_STYLE: Automatic + ENABLE_USER_SCRIPT_SANDBOXING: NO + configs: + # The release archive signs manually with the profile from the App + # Store Connect API. Automatic signing without an Xcode account does + # not work on this machine. + Release: + CODE_SIGN_STYLE: Manual + CODE_SIGN_IDENTITY: "Apple Distribution" + PROVISIONING_PROFILE_SPECIFIER: "ChainCheck AppStore" + preBuildScripts: + - name: Compile Kotlin Framework + script: | + cd "$SRCROOT/.." + ./gradlew :composeApp:embedAndSignAppleFrameworkForXcode + basedOnDependencyAnalysis: false + +schemes: + ChainCheck: + build: + targets: + ChainCheck: all + run: + config: Debug + archive: + config: Release diff --git a/scripts/testflight.py b/scripts/testflight.py new file mode 100644 index 0000000..cdcdca8 --- /dev/null +++ b/scripts/testflight.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""TestFlight automation steps for CI. + +Commands: + check-age Exits with code 3 when the newest build has more than 35 days + of life left. The workflow then stops; no new build is needed. + finish Waits until the newest build is processed, confirms the export + compliance, attaches the build to the public beta group, and + submits it for beta review. + +Credentials come from the environment: ASC_KEY_ID, ASC_ISSUER_ID, ASC_APP_ID, +ASC_GROUP_ID, and the key file at ~/.appstoreconnect/private_keys/. +""" +import datetime +import json +import os +import sys +import time +import urllib.error +import urllib.request + +import jwt + +BASE = "https://api.appstoreconnect.apple.com/v1" +REFRESH_BEFORE_DAYS = 35 + + +def token(): + key_id = os.environ["ASC_KEY_ID"] + with open(os.path.expanduser(f"~/.appstoreconnect/private_keys/AuthKey_{key_id}.p8")) as f: + pk = f.read() + now = int(time.time()) + return jwt.encode( + {"iss": os.environ["ASC_ISSUER_ID"], "iat": now, "exp": now + 900, + "aud": "appstoreconnect-v1"}, + pk, algorithm="ES256", headers={"kid": key_id, "typ": "JWT"}) + + +def call(method, path, body=None, ok_codes=()): + req = urllib.request.Request( + BASE + path, method=method, + headers={"Authorization": f"Bearer {token()}", "Content-Type": "application/json"}, + data=json.dumps(body).encode() if body else None) + try: + with urllib.request.urlopen(req) as r: + return json.load(r) if r.status != 204 else {} + except urllib.error.HTTPError as e: + if e.code in ok_codes: + print(f"HTTP {e.code} accepted for {path}") + return {} + print(f"HTTP {e.code}: {e.read().decode()[:400]}", file=sys.stderr) + raise SystemExit(1) + + +def newest_build(): + app = os.environ["ASC_APP_ID"] + builds = call("GET", f"/builds?filter[app]={app}&sort=-uploadedDate&limit=1") + return builds["data"][0] if builds["data"] else None + + +def check_age(): + build = newest_build() + if build is None: + print("no builds; a new build is needed") + return + expires = datetime.datetime.fromisoformat(build["attributes"]["expirationDate"]) + days = (expires - datetime.datetime.now(datetime.timezone.utc)).days + print(f"newest build expires {expires:%Y-%m-%d}; {days} days remain") + if days > REFRESH_BEFORE_DAYS: + print("the build is fresh; no upload is needed") + sys.exit(3) + print("the build is near expiry; a new build is needed") + + +def finish(): + # The upload from xcodebuild completed just before this step. Wait for the + # newest build to reach the processed state. + build = None + for _ in range(60): + build = newest_build() + state = build["attributes"]["processingState"] if build else "NONE" + print("processing state:", state) + if state == "VALID": + break + if state in ("FAILED", "INVALID"): + sys.exit("the build did not process") + time.sleep(30) + else: + sys.exit("the build did not process in time") + + build_id = build["id"] + # Info.plist already declares ITSAppUsesNonExemptEncryption, so the value is + # set at upload and the API refuses a second write. That answer is fine. + call("PATCH", f"/builds/{build_id}", {"data": { + "type": "builds", "id": build_id, + "attributes": {"usesNonExemptEncryption": False}}}, ok_codes=(409, 422)) + print("compliance confirmed") + + call("POST", f"/betaGroups/{os.environ['ASC_GROUP_ID']}/relationships/builds", + {"data": [{"type": "builds", "id": build_id}]}, ok_codes=(409,)) + print("build attached to the public group") + + call("POST", "/betaAppReviewSubmissions", {"data": { + "type": "betaAppReviewSubmissions", + "relationships": {"build": {"data": {"type": "builds", "id": build_id}}}}}, + ok_codes=(409, 422)) + print("beta review submitted") + + +if __name__ == "__main__": + {"check-age": check_age, "finish": finish}[sys.argv[1]]()