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]]()