diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9406332bc..95a658700 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,7 +133,7 @@ jobs: # Includes temporary downstream UniFFI callback vtable patch: # https://github.com/mozilla/uniffi-rs/pull/2821 - name: Build the Swift project (with temporary UniFFI ASan workaround) - run: ./swift/build_swift.sh + run: cargo xtask swift build - name: Upload Swift build artifacts uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 @@ -166,6 +166,11 @@ jobs: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Set up Rust + uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master + with: + toolchain: 1.94.1 + - name: Select Xcode ${{ matrix.xcode-version }} uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: @@ -178,7 +183,7 @@ jobs: path: swift - name: Run Swift foreign binding tests - run: ./swift/run_swift_tests.sh + run: cargo xtask swift test --skip-build - name: Install SwiftLint run: | diff --git a/.github/workflows/release-swift-kotlin.yml b/.github/workflows/release-swift-kotlin.yml index 30b1f61ad..3c56e50ea 100644 --- a/.github/workflows/release-swift-kotlin.yml +++ b/.github/workflows/release-swift-kotlin.yml @@ -6,7 +6,7 @@ on: env: # The feature flags with which the Android library is built. - # For iOS, this is set in the `build_swift.sh` file. + # Used by both the Kotlin and Swift xtasks. WALLETKIT_CARGO_FEATURES: compress-zkeys,embed-zkeys,v3 jobs: @@ -58,7 +58,7 @@ jobs: # Includes temporary downstream UniFFI callback vtable patch: # https://github.com/mozilla/uniffi-rs/pull/2821 - name: Build the project (iOS, with temporary UniFFI ASan workaround) - run: ./swift/build_swift.sh + run: cargo xtask swift build - name: Compress XCFramework binary run: | @@ -106,7 +106,7 @@ jobs: # Prepare Package.swift brew install swiftlint - ./swift/archive_swift.sh --asset-url "$ASSET_URL" --checksum "$CHECKSUM" --release-version "$NEW_VERSION" + cargo xtask swift archive --asset-url "$ASSET_URL" --checksum "$CHECKSUM" --release-version "$NEW_VERSION" cp Package.swift target-repo/ # Commit changes diff --git a/README.md b/README.md index fda3b3bd2..ea48846ff 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,21 @@ Replace `VERSION` with the desired WalletKit version. 2. Sync Gradle. +## Local development (iOS/Swift) + +On macOS with Xcode and the iOS Rust targets installed, build a local Swift +package with: + +```bash +cargo xtask swift local +``` + +The package is written to `swift/local_build/walletkit-swift`. Build only the +bindings and XCFramework with `cargo xtask swift build`, or run the foreign +binding tests with `cargo xtask swift test`. + +See [`swift/README.md`](swift/README.md) for package integration details. + ## Local development (Android/Kotlin) ### Prerequisites diff --git a/nix/README.md b/nix/README.md index 7da4a150c..bccfbb3f8 100644 --- a/nix/README.md +++ b/nix/README.md @@ -2,7 +2,7 @@ This directory contains the Nix devshells and helper scripts used to cross-compile WalletKit for Android and wasm, both locally and in CI. -Swift/iOS builds intentionally continue to use their existing host Xcode setup. +Swift/iOS builds intentionally use the host Xcode setup through `cargo xtask swift`. The flake provides **devshells, not packages**: the shells pin the Rust toolchain (from `rust-toolchain.toml`), the Android NDK, nargo and every diff --git a/nix/docker.sh b/nix/docker.sh index 870ed0934..f43dc887c 100755 --- a/nix/docker.sh +++ b/nix/docker.sh @@ -11,7 +11,7 @@ set -euo pipefail # nix/docker.sh flake show # # Swift builds are not provided by the flake because they depend on macOS and -# the host Xcode configuration; use the existing Swift build flow instead. +# the host Xcode configuration; use `cargo xtask swift` on macOS instead. cd "$(dirname "${BASH_SOURCE[0]}")/.." diff --git a/swift/README.md b/swift/README.md index 8f0e979b9..51809ca62 100644 --- a/swift/README.md +++ b/swift/README.md @@ -2,42 +2,45 @@ This folder contains Swift support files for WalletKit: -1. Script to cross-compile and build Swift bindings. -2. Script to build a Swift package for local development. -3. Foreign tests (XCTest suite) for Swift under `tests/`. +1. Swift Package Manager configuration. +2. Foreign tests (XCTest suite) under `tests/`. + +Swift automation is implemented by the workspace xtask and requires macOS with +Xcode, the iOS Rust targets, and `nargo` v1.0.0-beta.11 installed. ## Building the Swift bindings -To build the Swift project for release/distribution: +Run from anywhere in the workspace: ```bash - # run from the walletkit directory - ./swift/build_swift.sh +cargo xtask swift build ``` +This cross-compiles WalletKit, generates the UniFFI Swift bindings, and creates +`swift/WalletKit.xcframework`. + ## Testing WalletKit locally -To build a Swift package that can be imported locally via Swift Package Manager: +Build a package that can be imported locally through Swift Package Manager: ```bash - # run from the walletkit directory - ./swift/local_swift.sh +cargo xtask swift local ``` -This creates a complete Swift package in `swift/local_build/` that you can import in your iOS project. +The complete package is created at `swift/local_build/walletkit-swift`. ## Integration via Package.swift -Add the local package to your Package.swift dependencies: +Add the local package to your `Package.swift` dependencies: ```swift dependencies: [ - .package(name: "WalletKit", path: "../../../walletkit/swift/local_build"), + .package(name: "WalletKit", path: "../../../walletkit/swift/local_build/walletkit-swift"), // ... other dependencies ], ``` -Then add it to specific targets that need WalletKit functionality: +Then add it to the targets that need WalletKit: ```swift .target( @@ -45,13 +48,19 @@ Then add it to specific targets that need WalletKit functionality: dependencies: [ .product(name: "WalletKit", package: "WalletKit"), // ... other dependencies - ] + ], ), ``` ## Running foreign tests for Swift ```bash - # run from the walletkit directory - ./swift/test_swift.sh +cargo xtask swift test +``` + +The command builds the bindings and runs the test suite on an available iPhone +simulator. To test artifacts built separately, as CI does, run: + +```bash +cargo xtask swift test --skip-build ``` diff --git a/swift/archive_swift.sh b/swift/archive_swift.sh deleted file mode 100755 index 288598803..000000000 --- a/swift/archive_swift.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/bin/bash -set -e - -# Creates the dynamic Package.swift file for release. -# Usage: ./archive_swift.sh --asset-url --checksum --release-version - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Initialize variables -ASSET_URL="" -CHECKSUM="" -RELEASE_VERSION="" - -# Function to show usage -show_usage() { - echo "โŒ Error: Missing required arguments" - echo "Usage: $0 --asset-url --checksum --release-version " - echo "" - echo "Example:" - echo " $0 --asset-url 'https://github.com/user/repo/releases/download/v1.0.0/WalletKit.xcframework.zip' --checksum 'abc123def456...' --release-version '1.0.0'" - exit 1 -} - -# Parse command line arguments -while [[ $# -gt 0 ]]; do - case $1 in - --asset-url) - ASSET_URL="$2" - shift 2 - ;; - --checksum) - CHECKSUM="$2" - shift 2 - ;; - --release-version) - RELEASE_VERSION="$2" - shift 2 - ;; - -h|--help) - show_usage - ;; - *) - echo "โŒ Unknown argument: $1" - show_usage - ;; - esac -done - -# Check if all required arguments are provided -if [ -z "$ASSET_URL" ] || [ -z "$CHECKSUM" ] || [ -z "$RELEASE_VERSION" ]; then - echo "โŒ Error: All arguments are required" - show_usage -fi - -echo "๐Ÿ”ง Creating Package.swift with:" -echo " Asset URL: $ASSET_URL" -echo " Checksum: $CHECKSUM" -echo " Release Version: $RELEASE_VERSION" -echo "" - -awk -v url="$ASSET_URL" -v checksum="$CHECKSUM" ' -// { - print " .binaryTarget(" - print " name: \"walletkit_coreFFI\"," - print " url: \"" url "\"," - print " checksum: \"" checksum "\"" - print " )" - next -} -{ print } -' "$SCRIPT_DIR/Package.swift.template" > Package.swift - -echo "// Release version: $RELEASE_VERSION" >> Package.swift - -swiftlint lint --autocorrect Package.swift - -echo "" -echo "โœ… Package.swift built successfully for version $RELEASE_VERSION!" diff --git a/swift/build_swift.sh b/swift/build_swift.sh deleted file mode 100755 index 2604f7e07..000000000 --- a/swift/build_swift.sh +++ /dev/null @@ -1,146 +0,0 @@ -#!/bin/bash -set -e - -# Creates a Swift build of the `WalletKit` library. -# This script can be used directly or called by other scripts. -# -# Usage: build_swift.sh [OUTPUT_DIR] -# OUTPUT_DIR: Directory where the XCFramework should be placed (default: swift/) - -PROJECT_ROOT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -BASE_PATH="$PROJECT_ROOT_PATH/swift" # The base path for the Swift build -source "$BASE_PATH/make_walletkit_framework.sh" -PACKAGE_NAME="walletkit" -TARGET_DIR="$PROJECT_ROOT_PATH/target" -SUPPORT_SOURCES_DIR="$BASE_PATH/support" -CARGO_FEATURES="compress-zkeys,embed-zkeys,v3" - -# Default values -OUTPUT_DIR="$BASE_PATH" # Default to BASE_PATH if not provided -FRAMEWORK="WalletKit.xcframework" - -# Parse arguments -while [[ $# -gt 0 ]]; do - case $1 in - --help|-h) - echo "Usage: $0 [OUTPUT_DIR]" - echo "" - echo "Arguments:" - echo " OUTPUT_DIR Directory where the XCFramework should be placed (default: swift/)" - echo "" - exit 0 - ;; - *) - # Assume it's the output directory if it doesn't start with -- - if [[ ! "$1" =~ ^-- ]]; then - OUTPUT_DIR="$1" - else - echo "Unknown option: $1" - echo "Use --help for usage information" - exit 1 - fi - shift - ;; - esac -done - -# Resolve OUTPUT_DIR to absolute path if it's relative -if [[ "$OUTPUT_DIR" != /* ]]; then - OUTPUT_DIR="$BASE_PATH/$OUTPUT_DIR" -fi - -SWIFT_SOURCES_DIR="$OUTPUT_DIR/Sources/WalletKit" -FRAMEWORK_OUTPUT="$OUTPUT_DIR/$FRAMEWORK" - -echo "Building $FRAMEWORK to $FRAMEWORK_OUTPUT" - -# Clean up previous builds -rm -rf "$BASE_PATH/ios_build" -rm -rf "$FRAMEWORK_OUTPUT" - -# Create necessary directories -mkdir -p "$BASE_PATH/ios_build/bindings" -mkdir -p "$BASE_PATH/ios_build/target/universal-ios-sim/release" -mkdir -p "$SWIFT_SOURCES_DIR" - -echo "Building Rust packages for iOS targets..." - -export IPHONEOS_DEPLOYMENT_TARGET="13.0" -# Workaround: aws-lc-sys's urandom.c references the Linux-only RNDGETENTCNT -# macro (from ) and calls ioctl() without including -# , both of which cause compilation errors on iOS. We define -# RNDGETENTCNT as a stub and suppress the implicit function declaration error. -# These flags are scoped to aws-lc-sys only (via AWS_LC_SYS_ prefix) so they -# don't affect other crates. The RNDGETENTCNT/ioctl code path is Linux-specific -# and never reached on iOS, where aws-lc uses SecRandomCopyBytes/getentropy() -# for entropy instead. -export AWS_LC_SYS_CFLAGS="-DRNDGETENTCNT=2 -Wno-implicit-function-declaration" -export RUSTFLAGS="-C link-arg=-Wl,-application_extension \ - -C link-arg=-Wl,-dead_strip \ - -C link-arg=-Wl,-dead_strip_dylibs \ - -C embed-bitcode=no" - -# Build for all iOS targets -cargo build --package $PACKAGE_NAME --target aarch64-apple-ios-sim --release \ - --manifest-path "$PROJECT_ROOT_PATH/Cargo.toml" --target-dir "$TARGET_DIR" --features "$CARGO_FEATURES" -cargo build --package $PACKAGE_NAME --target aarch64-apple-ios --release \ - --manifest-path "$PROJECT_ROOT_PATH/Cargo.toml" --target-dir "$TARGET_DIR" --features "$CARGO_FEATURES" -cargo build --package $PACKAGE_NAME --target x86_64-apple-ios --release \ - --manifest-path "$PROJECT_ROOT_PATH/Cargo.toml" --target-dir "$TARGET_DIR" --features "$CARGO_FEATURES" -echo "Rust packages built. Combining simulator targets into universal binary..." - -# Create universal binary for simulators -lipo -create "$TARGET_DIR/aarch64-apple-ios-sim/release/lib${PACKAGE_NAME}.a" \ - "$TARGET_DIR/x86_64-apple-ios/release/lib${PACKAGE_NAME}.a" \ - -output $BASE_PATH/ios_build/target/universal-ios-sim/release/lib${PACKAGE_NAME}.a - -lipo -info $BASE_PATH/ios_build/target/universal-ios-sim/release/lib${PACKAGE_NAME}.a - -echo "Generating Swift bindings..." - -# Generate Swift bindings using uniffi -cargo run -p uniffi-bindgen --manifest-path "$PROJECT_ROOT_PATH/Cargo.toml" \ - --target-dir "$TARGET_DIR" -- generate \ - "$TARGET_DIR/aarch64-apple-ios-sim/release/lib${PACKAGE_NAME}.dylib" \ - --library \ - --crate walletkit_core \ - --language swift \ - --no-format \ - --out-dir $BASE_PATH/ios_build/bindings - -# Move generated Swift file to Sources directory -mv $BASE_PATH/ios_build/bindings/walletkit_core.swift ${SWIFT_SOURCES_DIR}/walletkit.swift - -# Copy support Swift sources for the WalletKit module. -if [ -d "$SUPPORT_SOURCES_DIR" ]; then - rsync -a "$SUPPORT_SOURCES_DIR"/ "$SWIFT_SOURCES_DIR"/ -fi - -echo "Creating XCFramework..." - -# Assemble a proper walletkit_coreFFI.framework bundle per platform slice. -# `xcodebuild -create-xcframework -library/-headers` relies on Clang's -# implicit subdirectory modulemap search, which recent SDKs disabled โ€” a -# framework module isn't subject to that search. -make_walletkit_framework "$BASE_PATH/ios_build/Frameworks/ios-arm64/walletkit_coreFFI.framework" \ - "$TARGET_DIR/aarch64-apple-ios/release/lib${PACKAGE_NAME}.a" \ - iPhoneOS \ - "$BASE_PATH/ios_build/bindings/walletkit_coreFFI.h" \ - "$BASE_PATH/ios_build/bindings/walletkit_coreFFI.modulemap" - -make_walletkit_framework "$BASE_PATH/ios_build/Frameworks/ios-arm64_x86_64-simulator/walletkit_coreFFI.framework" \ - "$BASE_PATH/ios_build/target/universal-ios-sim/release/lib${PACKAGE_NAME}.a" \ - iPhoneSimulator \ - "$BASE_PATH/ios_build/bindings/walletkit_coreFFI.h" \ - "$BASE_PATH/ios_build/bindings/walletkit_coreFFI.modulemap" - -# Create XCFramework -xcodebuild -create-xcframework \ - -framework "$BASE_PATH/ios_build/Frameworks/ios-arm64/walletkit_coreFFI.framework" \ - -framework "$BASE_PATH/ios_build/Frameworks/ios-arm64_x86_64-simulator/walletkit_coreFFI.framework" \ - -output $FRAMEWORK_OUTPUT - -# Clean up intermediate build files -rm -rf $BASE_PATH/ios_build - -echo "โœ… Swift framework built successfully at: $FRAMEWORK_OUTPUT" diff --git a/swift/local_swift.sh b/swift/local_swift.sh deleted file mode 100755 index 7273bc976..000000000 --- a/swift/local_swift.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/bin/bash -set -e - -# Creates a Swift package of the `WalletKit` library for local development. -# This script builds the library and sets up the proper structure for importing -# via Swift Package Manager using a local file:// URL. -# All artifacts are placed in swift/local_build to keep the repo clean. - -PROJECT_ROOT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -BASE_PATH="$PROJECT_ROOT_PATH/swift" # The base path for the Swift build -LOCAL_BUILD_PATH="$BASE_PATH/local_build/walletkit-swift" # Local build artifacts directory -FRAMEWORK="WalletKit.xcframework" - -echo "Building $FRAMEWORK for local iOS development" - -# Clean up previous builds -rm -rf "$LOCAL_BUILD_PATH" - -# Create the local build directory -mkdir -p "$LOCAL_BUILD_PATH" - -echo "Running core Swift build..." - -# Call the main build script with local build directory -bash "$BASE_PATH/build_swift.sh" "$LOCAL_BUILD_PATH" - -echo "Creating Package.swift for local development..." - -awk -v path="$FRAMEWORK" ' -// { - print " .binaryTarget(" - print " name: \"walletkit_coreFFI\"," - print " path: \"" path "\"" - print " )" - next -} -{ print } -' "$BASE_PATH/Package.swift.template" > "$LOCAL_BUILD_PATH/Package.swift" - -echo "" -echo "โœ… Swift package built successfully!" -echo "" -echo "๐Ÿ“ฆ Package location: $LOCAL_BUILD_PATH" -echo "" -echo "To use this package in your iOS app:" -echo "1. In Xcode, go to File โ†’ Add Package Dependencies..." -echo "2. Click 'Add Local...' and select the local_build directory: $LOCAL_BUILD_PATH" -echo "3. Or add it to your Package.swift dependencies:" -echo " .package(path: \"$LOCAL_BUILD_PATH\")" -echo "" -echo "The package exports the 'WalletKit' library that you can import in your Swift code." diff --git a/swift/make_local_swift_package.sh b/swift/make_local_swift_package.sh index 4f9466999..e147372ef 100755 --- a/swift/make_local_swift_package.sh +++ b/swift/make_local_swift_package.sh @@ -19,7 +19,8 @@ fi LOCAL_BUILD_PATH="$SCRIPT_DIR/local_build/walletkit-swift" echo "Building the WalletKit Swift package..." -bash "$SCRIPT_DIR/local_swift.sh" +cd "$PROJECT_ROOT" +cargo xtask swift local echo "Finding Package.swift files that reference walletkit-swift..." PACKAGE_FILES=$(grep -rl "worldcoin/walletkit-swift" "$WORLD_APP_PATH" --include="Package.swift" --exclude-dir=".build" --exclude-dir=".claude" || true) diff --git a/swift/make_walletkit_framework.sh b/swift/make_walletkit_framework.sh deleted file mode 100755 index c777bddbe..000000000 --- a/swift/make_walletkit_framework.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/bin/bash -# Assembles one `walletkit_coreFFI.framework` slice from a static library and -# the UniFFI-generated header/modulemap, as a real framework bundle (Info.plist -# + Modules/module.modulemap) rather than a flat headers dir. Clang's implicit -# subdirectory modulemap search โ€” which `xcodebuild -create-xcframework -# -library/-headers` relies on โ€” was disabled on recent SDKs, breaking module -# lookup for consumers. A proper framework module isn't subject to that search. -# -# Meant to be sourced by build_swift.sh, not run directly. - -make_walletkit_framework() { - local framework_dir="$1" - local static_lib_path="$2" - local platform="$3" - local header_path="$4" - local modulemap_path="$5" - - rm -rf "$framework_dir" - mkdir -p "$framework_dir/Headers" "$framework_dir/Modules" - - cp "$static_lib_path" "$framework_dir/walletkit_coreFFI" - cp "$header_path" "$framework_dir/Headers/" - - sed 's/^module /framework module /' "$modulemap_path" > "$framework_dir/Modules/module.modulemap" - - cat > "$framework_dir/Info.plist" << EOF - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - walletkit_coreFFI - CFBundleIdentifier - org.worldcoin.walletkit-coreFFI - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - walletkit_coreFFI - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - CFBundleSupportedPlatforms - - ${platform} - - MinimumOSVersion - 13.0 - - -EOF -} diff --git a/swift/run_swift_tests.sh b/swift/run_swift_tests.sh deleted file mode 100755 index e2c4646cf..000000000 --- a/swift/run_swift_tests.sh +++ /dev/null @@ -1,167 +0,0 @@ -#!/bin/bash -set -e - -# Runs the Swift foreign-binding test suite against an already-built -# WalletKit.xcframework (from build_swift.sh). Split out from test_swift.sh -# so CI can build once and re-run this โ€” the actual import/link/run step, -# and the one that exercises the Xcode 26.4+ modulemap-discovery regression โ€” -# across an Xcode version matrix without repeating the expensive Rust build. - -echo "=========================================" -echo "Running Swift Tests" -echo "=========================================" - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[0;33m' -NC='\033[0m' # No Color - -# Check if iOS Simulator SDK is installed -if ! xcodebuild -showsdks | grep -q 'iphonesimulator'; then - echo -e "${RED}โœ— No iOS Simulator SDK installed${NC}" - echo "Available SDKs:" - xcodebuild -showsdks || true - exit 1 -fi - -# Base paths -BASE_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -TESTS_PATH="$BASE_PATH/tests" -SOURCES_PATH_NAME="/Sources/WalletKit/" - -if [ ! -d "$BASE_PATH/WalletKit.xcframework" ]; then - echo -e "${RED}โœ— WalletKit.xcframework not found at $BASE_PATH/WalletKit.xcframework โ€” run build_swift.sh first${NC}" - exit 1 -fi - -echo -e "${BLUE}๐Ÿ“ฆ Copying generated Swift files to test package${NC}" -mkdir -p "$TESTS_PATH$SOURCES_PATH_NAME" - -if [ -d "$BASE_PATH/Sources/WalletKit" ]; then - rsync -a "$BASE_PATH/Sources/WalletKit"/ "$TESTS_PATH$SOURCES_PATH_NAME" - echo -e "${GREEN}โœ… Swift sources copied to test package${NC}" -else - echo -e "${RED}โœ— Could not find generated Swift sources at: $BASE_PATH/Sources/WalletKit${NC}" - exit 1 -fi - -echo "" -echo -e "${BLUE}๐Ÿงช Running Swift tests with verbose output...${NC}" -echo "" - -# Clean any previous build artifacts -rm -rf "$TESTS_PATH/.build" -rm -rf ~/Library/Developer/Xcode/DerivedData/WalletKitForeignTestPackage-* - -# Find an available iPhone simulator -SIMULATOR_ID=$(xcrun simctl list devices available | grep "iPhone 16" | head -1 | grep -o "[0-9A-F\-]*" | tail -1) - -if [ -z "$SIMULATOR_ID" ]; then - # Try any available iPhone - SIMULATOR_ID=$(xcrun simctl list devices available | grep "iPhone" | head -1 | grep -o "[0-9A-F\-]*" | tail -1) -fi - -if [ -z "$SIMULATOR_ID" ]; then - echo -e "${RED}โœ— No iPhone simulator available${NC}" - exit 1 -fi - -# ------------------------------------------------------------------ -# Simulator hygiene: clear residual state that intermittently prevents -# the test runner from launching inside the device ("Test runner never -# began executing tests after launching" timeout observed in CI). -# ------------------------------------------------------------------ -if [ "${GITHUB_ACTIONS:-false}" = "true" ] || [ "${CI:-false}" = "true" ]; then - echo "๐Ÿงน Running simulator hygiene (CI environment detected)..." - xcrun simctl shutdown "$SIMULATOR_ID" >/dev/null 2>&1 || true - xcrun simctl erase "$SIMULATOR_ID" - xcrun simctl boot "$SIMULATOR_ID" - xcrun simctl bootstatus "$SIMULATOR_ID" -b # wait until boot completes -else - echo "๐Ÿ’ป Local environment detected - skipping simulator hygiene" -fi - -echo "๐Ÿ“ฑ Using simulator ID: $SIMULATOR_ID" - -cd "$TESTS_PATH" - -# Run tests using xcodebuild for iOS simulator with more explicit settings -echo "๐Ÿš€ Running tests on iOS Simulator..." -xcodebuild test \ - -scheme WalletKitForeignTestPackage \ - -destination "platform=iOS Simulator,id=$SIMULATOR_ID" \ - -sdk iphonesimulator \ - CODE_SIGNING_ALLOWED=NO \ - 2>&1 | tee test_output.log - -echo "" -echo "๐Ÿ“Š Test Results Summary:" -echo "========================" - -# Parse test results from the output -TOTAL_TESTS=0 -PASSED_TESTS=0 -FAILED_TESTS=0 -TEST_SUITES_PASSED=0 -TEST_SUITES_FAILED=0 - -if [ -f test_output.log ]; then - echo "โœ… Test results found in: test_output.log" - - # Count test cases - ensure we get valid integers - TOTAL_TESTS=$(grep -c "Test Case.*started" test_output.log 2>/dev/null || echo "0") - TOTAL_TESTS=${TOTAL_TESTS%%[^0-9]*} # Remove any non-numeric characters - TOTAL_TESTS=${TOTAL_TESTS:-0} # Default to 0 if empty - - PASSED_TESTS=$(grep -c "Test Case.*passed" test_output.log 2>/dev/null || echo "0") - PASSED_TESTS=${PASSED_TESTS%%[^0-9]*} - PASSED_TESTS=${PASSED_TESTS:-0} - - FAILED_TESTS=$(grep -c "Test Case.*failed" test_output.log 2>/dev/null || echo "0") - FAILED_TESTS=${FAILED_TESTS%%[^0-9]*} - FAILED_TESTS=${FAILED_TESTS:-0} - - # Count test suites - ensure we get valid integers - TEST_SUITES_PASSED=$(grep -c "Test Suite.*passed" test_output.log 2>/dev/null || echo "0") - TEST_SUITES_PASSED=${TEST_SUITES_PASSED%%[^0-9]*} - TEST_SUITES_PASSED=${TEST_SUITES_PASSED:-0} - - TEST_SUITES_FAILED=$(grep -c "Test Suite.*failed" test_output.log 2>/dev/null || echo "0") - TEST_SUITES_FAILED=${TEST_SUITES_FAILED%%[^0-9]*} - TEST_SUITES_FAILED=${TEST_SUITES_FAILED:-0} - - echo "๐Ÿ“‹ Total test cases: $TOTAL_TESTS" - echo "โœ… Tests passed: $PASSED_TESTS" - echo "โŒ Tests failed: $FAILED_TESTS" - echo "โš ๏ธ Test errors: 0" - - if [ "$TEST_SUITES_FAILED" -gt 0 ]; then - echo "๐Ÿ“ฆ Test suites failed: $TEST_SUITES_FAILED" - fi -else - echo "โš ๏ธ No test results found" -fi - -# Check if tests passed by examining the output -if grep -q "failed" test_output.log; then - echo "" - echo -e "${YELLOW}โš ๏ธ Some tests failed${NC}" - echo "Failed test details:" - grep -E "(failed|error:)" test_output.log || true - rm -f test_output.log - exit 1 -elif grep -q "Test Suite.*passed" test_output.log; then - echo "" - echo -e "${GREEN}๐ŸŽ‰ All tests passed!${NC}" - rm -f test_output.log - exit 0 -else - echo "" - echo -e "${RED}โœ— Could not determine test results${NC}" - echo "Full output:" - cat test_output.log - rm -f test_output.log - exit 1 -fi diff --git a/swift/test_swift.sh b/swift/test_swift.sh deleted file mode 100755 index cdf0c0d56..000000000 --- a/swift/test_swift.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash -set -e - -# Builds the Swift bindings and runs the foreign-binding test suite locally. -# In CI this is split in two (see .github/workflows/ci.yml): build_swift.sh -# runs once, and run_swift_tests.sh re-runs against that same build across -# an Xcode version matrix, so the expensive Rust cross-compilation isn't -# repeated per Xcode version. - -BASE_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -echo -e "\033[0;34m๐Ÿ”จ Step 1: Building Swift bindings\033[0m" -# Must cd to the repository root first because build script expects to run from there -cd "$BASE_PATH/.." && bash ./swift/build_swift.sh - -bash "$BASE_PATH/run_swift_tests.sh" diff --git a/swift/tests/README.md b/swift/tests/README.md index 45df75b3a..5b3ac2640 100644 --- a/swift/tests/README.md +++ b/swift/tests/README.md @@ -7,17 +7,17 @@ Foreign binding tests that verify the FFI (Foreign Function Interface) between R From the repository root: ```bash -./swift/test_swift.sh +cargo xtask swift test ``` -This script will automatically: +This command will automatically: 1. Build the Rust library for iOS targets 2. Generate Swift bindings via UniFFI 3. Copy bindings to the test package 4. Run the tests on iOS Simulator -**Note:** You don't need to run `build_swift.sh` separately - the test script does it for you. +**Note:** You don't need to run the build task separately; the test task builds the Swift artifacts before running the suite. Use `--skip-build` to test existing artifacts. ## What These Tests Verify diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 520eeeed5..dcb33bf37 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,6 +1,7 @@ //! Repository automation for `WalletKit`. mod kotlin; +mod swift; use std::path::Path; @@ -22,6 +23,12 @@ enum Command { #[command(subcommand)] command: kotlin::Command, }, + + /// Swift/iOS automation. + Swift { + #[command(subcommand)] + command: swift::Command, + }, } fn main() -> Result<()> { @@ -31,6 +38,7 @@ fn main() -> Result<()> { match cli.command { Command::Kotlin { command } => kotlin::run(&sh, &command), + Command::Swift { command } => swift::run(&sh, &command), } } diff --git a/xtask/src/swift/archive.rs b/xtask/src/swift/archive.rs new file mode 100644 index 000000000..3ea55786c --- /dev/null +++ b/xtask/src/swift/archive.rs @@ -0,0 +1,39 @@ +//! Release Swift package manifest generation. + +use eyre::{ensure, Result, WrapErr as _}; +use xshell::{cmd, Shell}; + +use super::package; + +const PACKAGE_TEMPLATE: &str = "swift/Package.swift.template"; +const RELEASE_MANIFEST: &str = "Package.swift"; + +pub(super) fn run( + sh: &Shell, + asset_url: &str, + checksum: &str, + release_version: &str, +) -> Result<()> { + ensure!(!asset_url.trim().is_empty(), "asset URL must not be empty"); + ensure!(!checksum.trim().is_empty(), "checksum must not be empty"); + ensure!( + !release_version.trim().is_empty(), + "release version must not be empty" + ); + + println!("Creating release Package.swift for WalletKit {release_version}..."); + let template = sh + .read_file(PACKAGE_TEMPLATE) + .wrap_err_with(|| format!("failed to read {PACKAGE_TEMPLATE}"))?; + let manifest = + package::release_manifest(&template, asset_url, checksum, release_version)?; + sh.write_file(RELEASE_MANIFEST, manifest) + .wrap_err_with(|| format!("failed to write {RELEASE_MANIFEST}"))?; + + cmd!(sh, "swiftlint lint --autocorrect {RELEASE_MANIFEST}") + .run() + .wrap_err("failed to lint the release Package.swift")?; + + println!("Package.swift built successfully for version {release_version}."); + Ok(()) +} diff --git a/xtask/src/swift/build.rs b/xtask/src/swift/build.rs new file mode 100644 index 000000000..3ad701a0d --- /dev/null +++ b/xtask/src/swift/build.rs @@ -0,0 +1,380 @@ +//! iOS library, Swift binding, and `XCFramework` generation. + +use std::path::{Path, PathBuf}; + +use eyre::{bail, Result, WrapErr as _}; +use xshell::{cmd, Shell}; + +const DEFAULT_CARGO_FEATURES: &str = "compress-zkeys,embed-zkeys,v3"; +const FRAMEWORK_NAME: &str = "WalletKit.xcframework"; +const INTERMEDIATE_DIR: &str = "swift/ios_build"; +const PACKAGE_NAME: &str = "walletkit"; +const SUPPORT_SOURCES_DIR: &str = "swift/support"; +const TARGET_DIR: &str = "target"; + +const IOS_TARGETS: [&str; 3] = [ + "aarch64-apple-ios-sim", + "aarch64-apple-ios", + "x86_64-apple-ios", +]; + +struct FrameworkSlice<'a> { + directory: &'a str, + library: PathBuf, + platform: &'a str, +} + +pub(super) fn run(sh: &Shell, output_dir: Option<&Path>) -> Result<()> { + ensure_macos()?; + ensure_ios_sdks(sh)?; + ensure_nargo(sh)?; + + let output_dir = resolve_output_dir(output_dir); + let sources_dir = output_dir.join("Sources/WalletKit"); + let framework_output = output_dir.join(FRAMEWORK_NAME); + + println!( + "Building {FRAMEWORK_NAME} to {}", + framework_output.display() + ); + + sh.remove_path(INTERMEDIATE_DIR)?; + sh.remove_path(&framework_output)?; + sh.create_dir(format!("{INTERMEDIATE_DIR}/bindings"))?; + sh.create_dir(format!( + "{INTERMEDIATE_DIR}/target/universal-ios-sim/release" + ))?; + sh.create_dir(&sources_dir)?; + + configure_ios_build(sh); + build_native_libraries(sh)?; + create_universal_simulator_library(sh)?; + generate_bindings(sh, &sources_dir)?; + create_xcframework(sh, &framework_output)?; + + sh.remove_path(INTERMEDIATE_DIR)?; + + println!( + "Swift framework built successfully at: {}", + framework_output.display() + ); + Ok(()) +} + +fn ensure_macos() -> Result<()> { + if !cfg!(target_os = "macos") { + bail!("Swift/iOS builds require macOS and Xcode"); + } + Ok(()) +} + +fn ensure_ios_sdks(sh: &Shell) -> Result<()> { + for sdk in ["iphoneos", "iphonesimulator"] { + if cmd!(sh, "xcrun --sdk {sdk} --show-sdk-path") + .quiet() + .ignore_stdout() + .ignore_stderr() + .run() + .is_err() + { + bail!( + "Xcode SDK `{sdk}` is unavailable; select a full Xcode installation, for example:\n \ + sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer" + ); + } + } + Ok(()) +} + +fn ensure_nargo(sh: &Shell) -> Result<()> { + if cmd!(sh, "nargo --version") + .quiet() + .ignore_stdout() + .ignore_stderr() + .run() + .is_err() + { + bail!( + "nargo v1.0.0-beta.11 is required; install it with noirup or use the Nix default devshell" + ); + } + Ok(()) +} + +fn resolve_output_dir(output_dir: Option<&Path>) -> PathBuf { + output_dir.map_or_else( + || PathBuf::from("swift"), + |directory| { + if directory.is_absolute() { + directory.to_path_buf() + } else { + Path::new("swift").join(directory) + } + }, + ) +} + +fn configure_ios_build(sh: &Shell) { + sh.set_var("IPHONEOS_DEPLOYMENT_TARGET", "13.0"); + // aws-lc-sys references Linux-only entropy definitions while compiling an + // unreachable iOS code path. Keep this workaround scoped to aws-lc-sys. + sh.set_var( + "AWS_LC_SYS_CFLAGS", + "-DRNDGETENTCNT=2 -Wno-implicit-function-declaration", + ); + sh.set_var( + "RUSTFLAGS", + "-C link-arg=-Wl,-application_extension \ + -C link-arg=-Wl,-dead_strip \ + -C link-arg=-Wl,-dead_strip_dylibs \ + -C embed-bitcode=no", + ); +} + +fn build_native_libraries(sh: &Shell) -> Result<()> { + let features = cargo_features(sh); + println!("Building WalletKit for iOS targets..."); + + for target in IOS_TARGETS { + println!("Building {target}..."); + cmd!( + sh, + "cargo build --package {PACKAGE_NAME} --target {target} --release --locked --target-dir {TARGET_DIR} --features {features}" + ) + .run() + .wrap_err_with(|| format!("failed to build WalletKit for {target}"))?; + } + + Ok(()) +} + +fn cargo_features(sh: &Shell) -> String { + sh.var("WALLETKIT_CARGO_FEATURES") + .ok() + .filter(|features| !features.is_empty()) + .unwrap_or_else(|| DEFAULT_CARGO_FEATURES.to_owned()) +} + +fn create_universal_simulator_library(sh: &Shell) -> Result<()> { + let arm_library = + Path::new(TARGET_DIR).join("aarch64-apple-ios-sim/release/libwalletkit.a"); + let intel_library = + Path::new(TARGET_DIR).join("x86_64-apple-ios/release/libwalletkit.a"); + let output = Path::new(INTERMEDIATE_DIR) + .join("target/universal-ios-sim/release/libwalletkit.a"); + + println!("Combining simulator targets into a universal binary..."); + cmd!( + sh, + "lipo -create {arm_library} {intel_library} -output {output}" + ) + .run() + .wrap_err("failed to create universal iOS simulator library")?; + cmd!(sh, "lipo -info {output}") + .run() + .wrap_err("failed to inspect universal iOS simulator library") +} + +fn generate_bindings(sh: &Shell, sources_dir: &Path) -> Result<()> { + let library = + Path::new(TARGET_DIR).join("aarch64-apple-ios-sim/release/libwalletkit.dylib"); + let bindings_dir = Path::new(INTERMEDIATE_DIR).join("bindings"); + + println!("Generating Swift bindings..."); + cmd!( + sh, + "cargo run -p uniffi-bindgen --locked --target-dir {TARGET_DIR} -- generate {library} --library --crate walletkit_core --language swift --no-format --out-dir {bindings_dir}" + ) + .run() + .wrap_err("failed to generate Swift bindings")?; + + let generated_source = bindings_dir.join("walletkit_core.swift"); + let destination = sources_dir.join("walletkit.swift"); + sh.copy_file(&generated_source, &destination) + .wrap_err_with(|| { + format!( + "failed to move {} to {}", + generated_source.display(), + destination.display() + ) + })?; + sh.remove_path(&generated_source)?; + + if sh.path_exists(SUPPORT_SOURCES_DIR) { + copy_directory_contents(sh, Path::new(SUPPORT_SOURCES_DIR), sources_dir)?; + } + + Ok(()) +} + +fn copy_directory_contents( + sh: &Shell, + source: &Path, + destination: &Path, +) -> Result<()> { + for source_path in sh + .read_dir(source) + .wrap_err_with(|| format!("failed to read {}", source.display()))? + { + let file_name = source_path + .file_name() + .expect("directory entries must have a file name"); + let destination_path = destination.join(file_name); + + if source_path.is_dir() { + sh.create_dir(&destination_path)?; + copy_directory_contents(sh, &source_path, &destination_path)?; + } else { + sh.copy_file(&source_path, &destination_path) + .wrap_err_with(|| { + format!( + "failed to copy {} to {}", + source_path.display(), + destination_path.display() + ) + })?; + } + } + + Ok(()) +} + +fn create_xcframework(sh: &Shell, framework_output: &Path) -> Result<()> { + let bindings_dir = Path::new(INTERMEDIATE_DIR).join("bindings"); + let slices = [ + FrameworkSlice { + directory: "ios-arm64", + library: Path::new(TARGET_DIR) + .join("aarch64-apple-ios/release/libwalletkit.a"), + platform: "iPhoneOS", + }, + FrameworkSlice { + directory: "ios-arm64_x86_64-simulator", + library: Path::new(INTERMEDIATE_DIR) + .join("target/universal-ios-sim/release/libwalletkit.a"), + platform: "iPhoneSimulator", + }, + ]; + + println!("Creating XCFramework..."); + for slice in &slices { + let framework_dir = Path::new(INTERMEDIATE_DIR) + .join("Frameworks") + .join(slice.directory) + .join("walletkit_coreFFI.framework"); + make_framework( + sh, + &framework_dir, + &slice.library, + slice.platform, + &bindings_dir.join("walletkit_coreFFI.h"), + &bindings_dir.join("walletkit_coreFFI.modulemap"), + )?; + } + + let device_framework = Path::new(INTERMEDIATE_DIR) + .join("Frameworks/ios-arm64/walletkit_coreFFI.framework"); + let simulator_framework = Path::new(INTERMEDIATE_DIR) + .join("Frameworks/ios-arm64_x86_64-simulator/walletkit_coreFFI.framework"); + cmd!( + sh, + "xcodebuild -create-xcframework -framework {device_framework} -framework {simulator_framework} -output {framework_output}" + ) + .run() + .wrap_err("failed to create WalletKit XCFramework") +} + +fn make_framework( + sh: &Shell, + framework_dir: &Path, + static_library: &Path, + platform: &str, + header: &Path, + modulemap: &Path, +) -> Result<()> { + sh.remove_path(framework_dir)?; + sh.create_dir(framework_dir.join("Headers"))?; + sh.create_dir(framework_dir.join("Modules"))?; + sh.copy_file(static_library, framework_dir.join("walletkit_coreFFI"))?; + sh.copy_file(header, framework_dir.join("Headers/walletkit_coreFFI.h"))?; + + let modulemap_contents = sh + .read_file(modulemap) + .wrap_err_with(|| format!("failed to read {}", modulemap.display()))?; + let framework_modulemap = modulemap_contents + .lines() + .map(|line| { + line.strip_prefix("module ").map_or_else( + || line.to_owned(), + |rest| format!("framework module {rest}"), + ) + }) + .collect::>() + .join("\n"); + sh.write_file( + framework_dir.join("Modules/module.modulemap"), + format!("{framework_modulemap}\n"), + )?; + + sh.write_file( + framework_dir.join("Info.plist"), + framework_info_plist(platform), + )?; + + Ok(()) +} + +fn framework_info_plist(platform: &str) -> String { + format!( + r#" + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + walletkit_coreFFI + CFBundleIdentifier + org.worldcoin.walletkit-coreFFI + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + walletkit_coreFFI + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + CFBundleSupportedPlatforms + + {platform} + + MinimumOSVersion + 13.0 + + +"# + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_output_directories_are_resolved_under_swift() { + assert_eq!( + resolve_output_dir(Some(Path::new("release"))), + Path::new("swift/release") + ); + assert_eq!(resolve_output_dir(None), Path::new("swift")); + } + + #[test] + fn framework_plist_contains_platform() { + let plist = framework_info_plist("iPhoneSimulator"); + assert!(plist.contains("iPhoneSimulator")); + assert!(plist.contains("13.0")); + } +} diff --git a/xtask/src/swift/local.rs b/xtask/src/swift/local.rs new file mode 100644 index 000000000..dd6438c50 --- /dev/null +++ b/xtask/src/swift/local.rs @@ -0,0 +1,34 @@ +//! Local Swift package generation. + +use std::path::Path; + +use eyre::{Result, WrapErr as _}; +use xshell::Shell; + +use super::{build, package}; + +const FRAMEWORK_NAME: &str = "WalletKit.xcframework"; +const LOCAL_BUILD_DIR: &str = "swift/local_build/walletkit-swift"; +const PACKAGE_TEMPLATE: &str = "swift/Package.swift.template"; + +pub(super) fn run(sh: &Shell) -> Result<()> { + println!("Building {FRAMEWORK_NAME} for local iOS development..."); + + sh.remove_path(LOCAL_BUILD_DIR)?; + sh.create_dir(LOCAL_BUILD_DIR)?; + build::run(sh, Some(Path::new("local_build/walletkit-swift")))?; + + println!("Creating Package.swift for local development..."); + let template = sh + .read_file(PACKAGE_TEMPLATE) + .wrap_err_with(|| format!("failed to read {PACKAGE_TEMPLATE}"))?; + let manifest = package::local_manifest(&template, FRAMEWORK_NAME)?; + let manifest_path = Path::new(LOCAL_BUILD_DIR).join("Package.swift"); + sh.write_file(&manifest_path, manifest) + .wrap_err_with(|| format!("failed to write {}", manifest_path.display()))?; + + println!("Swift package built successfully."); + println!("Package location: {LOCAL_BUILD_DIR}"); + println!("Add it with: .package(path: \"{LOCAL_BUILD_DIR}\")"); + Ok(()) +} diff --git a/xtask/src/swift/mod.rs b/xtask/src/swift/mod.rs new file mode 100644 index 000000000..7687d99d1 --- /dev/null +++ b/xtask/src/swift/mod.rs @@ -0,0 +1,78 @@ +//! Swift/iOS automation. + +mod archive; +mod build; +mod local; +mod package; +mod test; + +use std::path::PathBuf; + +use clap::{Args, Subcommand}; +use eyre::Result; +use xshell::Shell; + +/// Swift/iOS tasks. +#[derive(Subcommand)] +pub enum Command { + /// Build iOS libraries, generate Swift bindings, and create an `XCFramework`. + Build(BuildOptions), + + /// Build the Swift bindings and run the iOS foreign-binding tests. + Test(TestOptions), + + /// Build a Swift package for local development. + Local, + + /// Generate the release `Package.swift` manifest. + Archive(ArchiveOptions), +} + +/// Options for building the Swift/iOS library. +#[derive(Args)] +pub struct BuildOptions { + /// Directory in which to create the `XCFramework` and generated sources. + /// + /// Relative paths are resolved from the `swift` directory. Defaults to `swift`. + #[arg(value_name = "OUTPUT_DIR")] + output_dir: Option, +} + +/// Options for running Swift foreign-binding tests. +#[derive(Args)] +pub struct TestOptions { + /// Test an existing `swift/WalletKit.xcframework` without rebuilding it. + #[arg(long)] + skip_build: bool, +} + +/// Options for generating the release Swift package manifest. +#[derive(Args)] +pub struct ArchiveOptions { + /// URL of the zipped `XCFramework` release asset. + #[arg(long)] + asset_url: String, + + /// Swift Package Manager checksum of the release asset. + #[arg(long)] + checksum: String, + + /// `WalletKit` release version recorded in the generated manifest. + #[arg(long)] + release_version: String, +} + +/// Runs a Swift/iOS task. +pub fn run(sh: &Shell, command: &Command) -> Result<()> { + match command { + Command::Build(options) => build::run(sh, options.output_dir.as_deref()), + Command::Test(options) => test::run(sh, options.skip_build), + Command::Local => local::run(sh), + Command::Archive(options) => archive::run( + sh, + &options.asset_url, + &options.checksum, + &options.release_version, + ), + } +} diff --git a/xtask/src/swift/package.rs b/xtask/src/swift/package.rs new file mode 100644 index 000000000..4ee6a56af --- /dev/null +++ b/xtask/src/swift/package.rs @@ -0,0 +1,66 @@ +//! Swift package manifest generation. + +use eyre::{ensure, Result}; + +const BINARY_TARGET_PLACEHOLDER: &str = " "; + +pub(super) fn local_manifest(template: &str, framework: &str) -> Result { + replace_binary_target( + template, + &format!( + " .binaryTarget(\n name: \"walletkit_coreFFI\",\n path: \"{framework}\"\n )" + ), + ) +} + +pub(super) fn release_manifest( + template: &str, + asset_url: &str, + checksum: &str, + release_version: &str, +) -> Result { + let manifest = replace_binary_target( + template, + &format!( + " .binaryTarget(\n name: \"walletkit_coreFFI\",\n url: \"{asset_url}\",\n checksum: \"{checksum}\"\n )" + ), + )?; + + Ok(format!("{manifest}// Release version: {release_version}\n")) +} + +fn replace_binary_target(template: &str, replacement: &str) -> Result { + ensure!( + template.matches(BINARY_TARGET_PLACEHOLDER).count() == 1, + "Swift package template must contain exactly one {BINARY_TARGET_PLACEHOLDER} placeholder" + ); + + Ok(template.replace(BINARY_TARGET_PLACEHOLDER, replacement)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEMPLATE: &str = "targets: [\n \n]\n"; + + #[test] + fn renders_local_binary_target() -> Result<()> { + let manifest = local_manifest(TEMPLATE, "WalletKit.xcframework")?; + + assert!(manifest.contains("path: \"WalletKit.xcframework\"")); + assert!(!manifest.contains(BINARY_TARGET_PLACEHOLDER)); + Ok(()) + } + + #[test] + fn renders_release_binary_target_and_version() -> Result<()> { + let manifest = + release_manifest(TEMPLATE, "https://example.com/sdk.zip", "abc", "1.2.3")?; + + assert!(manifest.contains("url: \"https://example.com/sdk.zip\"")); + assert!(manifest.contains("checksum: \"abc\"")); + assert!(manifest.ends_with("// Release version: 1.2.3\n")); + Ok(()) + } +} diff --git a/xtask/src/swift/test.rs b/xtask/src/swift/test.rs new file mode 100644 index 000000000..bef76b71d --- /dev/null +++ b/xtask/src/swift/test.rs @@ -0,0 +1,234 @@ +//! Swift foreign-binding tests. + +use std::path::Path; + +use eyre::{bail, Result, WrapErr as _}; +use xshell::{cmd, Shell}; + +use super::build; + +const FRAMEWORK_DIR: &str = "swift/WalletKit.xcframework"; +const GENERATED_SOURCES_DIR: &str = "swift/Sources/WalletKit"; +const TEST_BUILD_DIR: &str = "swift/tests/.build"; +const TEST_SOURCES_DIR: &str = "swift/tests/Sources/WalletKit"; +const TESTS_DIR: &str = "swift/tests"; + +pub(super) fn run(sh: &Shell, skip_build: bool) -> Result<()> { + ensure_macos()?; + + if !skip_build { + build::run(sh, None)?; + } + + ensure_simulator_sdk(sh)?; + prepare_test_package(sh)?; + let simulator_id = find_simulator(sh)?; + prepare_simulator(sh, &simulator_id)?; + run_tests(sh, &simulator_id)?; + + println!("Swift foreign-binding tests passed."); + Ok(()) +} + +fn ensure_macos() -> Result<()> { + if !cfg!(target_os = "macos") { + bail!("Swift foreign-binding tests require macOS and Xcode"); + } + Ok(()) +} + +fn ensure_simulator_sdk(sh: &Shell) -> Result<()> { + let sdks = cmd!(sh, "xcodebuild -showsdks") + .read() + .wrap_err("failed to list installed Xcode SDKs")?; + if !sdks.contains("iphonesimulator") { + bail!("no iOS Simulator SDK is installed; available SDKs:\n{sdks}"); + } + Ok(()) +} + +fn prepare_test_package(sh: &Shell) -> Result<()> { + if !sh.path_exists(FRAMEWORK_DIR) { + bail!( + "{FRAMEWORK_DIR} is missing; run `cargo xtask swift build` first or omit --skip-build" + ); + } + if !sh.path_exists(GENERATED_SOURCES_DIR) { + bail!( + "{GENERATED_SOURCES_DIR} is missing; run `cargo xtask swift build` first or omit --skip-build" + ); + } + + sh.remove_path(TEST_SOURCES_DIR)?; + sh.create_dir(TEST_SOURCES_DIR)?; + copy_directory_contents( + sh, + Path::new(GENERATED_SOURCES_DIR), + Path::new(TEST_SOURCES_DIR), + )?; + + sh.remove_path(TEST_BUILD_DIR)?; + remove_derived_data(sh)?; + Ok(()) +} + +fn copy_directory_contents( + sh: &Shell, + source: &Path, + destination: &Path, +) -> Result<()> { + for source_path in sh + .read_dir(source) + .wrap_err_with(|| format!("failed to read {}", source.display()))? + { + let file_name = source_path + .file_name() + .expect("directory entries must have a file name"); + let destination_path = destination.join(file_name); + + if source_path.is_dir() { + sh.create_dir(&destination_path)?; + copy_directory_contents(sh, &source_path, &destination_path)?; + } else { + sh.copy_file(&source_path, &destination_path) + .wrap_err_with(|| { + format!( + "failed to copy {} to {}", + source_path.display(), + destination_path.display() + ) + })?; + } + } + + Ok(()) +} + +fn remove_derived_data(sh: &Shell) -> Result<()> { + let Some(home) = sh.var_os("HOME") else { + return Ok(()); + }; + let derived_data = Path::new(&home).join("Library/Developer/Xcode/DerivedData"); + if !derived_data.exists() { + return Ok(()); + } + + for path in sh + .read_dir(&derived_data) + .wrap_err_with(|| format!("failed to read {}", derived_data.display()))? + { + if path.file_name().is_some_and(|name| { + name.to_string_lossy() + .starts_with("WalletKitForeignTestPackage-") + }) { + sh.remove_path(path)?; + } + } + Ok(()) +} + +fn find_simulator(sh: &Shell) -> Result { + let devices = cmd!(sh, "xcrun simctl list devices available") + .read() + .wrap_err("failed to list available iOS simulators")?; + + simulator_id(&devices, "iPhone 16") + .or_else(|| simulator_id(&devices, "iPhone")) + .ok_or_else(|| eyre::eyre!("no available iPhone simulator was found")) +} + +fn simulator_id(devices: &str, preferred_name: &str) -> Option { + devices + .lines() + .filter(|line| line.contains(preferred_name)) + .find_map(|line| { + line.split(['(', ')']) + .map(str::trim) + .find(|value| is_uuid(value)) + .map(str::to_owned) + }) +} + +fn is_uuid(value: &str) -> bool { + value.len() == 36 + && value.bytes().enumerate().all(|(index, byte)| { + if matches!(index, 8 | 13 | 18 | 23) { + byte == b'-' + } else { + byte.is_ascii_hexdigit() + } + }) +} + +fn prepare_simulator(sh: &Shell, simulator_id: &str) -> Result<()> { + if !is_ci(sh) { + println!("Using local simulator without erasing it: {simulator_id}"); + return Ok(()); + } + + println!("Resetting simulator for CI: {simulator_id}"); + let _ = cmd!(sh, "xcrun simctl shutdown {simulator_id}") + .quiet() + .run(); + cmd!(sh, "xcrun simctl erase {simulator_id}") + .run() + .wrap_err("failed to erase iOS simulator")?; + cmd!(sh, "xcrun simctl boot {simulator_id}") + .run() + .wrap_err("failed to boot iOS simulator")?; + cmd!(sh, "xcrun simctl bootstatus {simulator_id} -b") + .run() + .wrap_err("iOS simulator failed to finish booting") +} + +fn is_ci(sh: &Shell) -> bool { + ["GITHUB_ACTIONS", "CI"].into_iter().any(|variable| { + sh.var(variable) + .is_ok_and(|value| value.eq_ignore_ascii_case("true")) + }) +} + +fn run_tests(sh: &Shell, simulator_id: &str) -> Result<()> { + println!("Running Swift tests on simulator {simulator_id}..."); + let destination = format!("platform=iOS Simulator,id={simulator_id}"); + let _tests_dir = sh.push_dir(TESTS_DIR); + cmd!( + sh, + "xcodebuild test -scheme WalletKitForeignTestPackage -destination {destination} -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO" + ) + .run() + .wrap_err("Swift foreign-binding tests failed") +} + +#[cfg(test)] +mod tests { + use super::*; + + const DEVICES: &str = "== Devices ==\n-- iOS 26.0 --\n iPhone 16 Pro (AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE) (Shutdown)\n iPhone SE (11111111-2222-3333-4444-555555555555) (Booted)\n"; + + #[test] + fn prefers_named_simulator() { + assert_eq!( + simulator_id(DEVICES, "iPhone 16").as_deref(), + Some("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE") + ); + } + + #[test] + fn finds_any_iphone_as_fallback() { + assert_eq!( + simulator_id(DEVICES, "iPhone").as_deref(), + Some("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE") + ); + } + + #[test] + fn skips_parenthesized_text_in_device_name() { + let devices = " iPhone SE (3rd generation) (11111111-2222-3333-4444-555555555555) (Shutdown)"; + + assert_eq!( + simulator_id(devices, "iPhone").as_deref(), + Some("11111111-2222-3333-4444-555555555555") + ); + } +}