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 0000000..1d1d6ff Binary files /dev/null and b/iosApp/ChainCheck/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ 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]]()