130 Widgets

Building tools. Learning to build tools. Learning to build learning tools.

2. XcodeGen and Swift Package Manager iOS

iOS projects have a reputation for unwieldy project files. The .xcodeproj format stores build settings, file references, and target configurations in a binary-ish plist structure (project.pbxproj) that produces nightmarish diffs and frequent merge conflicts on any team larger than one. FredCam sidesteps this with XcodeGen, generating the .xcodeproj from a clean YAML file that’s easy to read, edit, and version control.

XcodeGen: Code-First Project Definition

XcodeGen is a command-line tool that reads a project.yml file and produces a valid .xcodeproj. The generated project file is not checked into source control — it’s a build artifact, just like a compiled binary. Only project.yml is the source of truth.

# Install
brew install xcodegen

# Generate (run after any change to project.yml)
cd FredCam
xcodegen generate

This gives you several benefits over maintaining a hand-written .xcodeproj:

iOS Concept

The .xcodeproj bundle contains a file called project.pbxproj. Despite the .pbxproj extension, it’s a plain-text format (Apple’s old NeXT Property List syntax). It’s technically human-readable, but in practice it’s a content-addressed graph of UUIDs pointing to build phases, file references, and settings. Adding a single Swift file generates three to five new UUID entries and modifies several existing ones. Reviewing such a diff in a pull request is essentially impossible.

Anatomy of project.yml

Here’s the complete project.yml for FredCam, annotated:

name: FredCam
options:
  bundleIdPrefix: com.bojordan    # prepended to each target's bundle ID
  deploymentTarget:
    iOS: "26.0"                   # minimum iOS version
  xcodeVersion: "16.0"           # Xcode version used to generate

packages:
  KSPlayer:
    url: https://github.com/kingslay/KSPlayer.git
    from: "2.2.0"                 # SPM version constraint: >= 2.2.0, < 3.0.0

targets:
  FredCam:
    type: application             # iOS application target
    platform: iOS
    sources:
      - FredCam                   # compile everything in the FredCam/ directory
    settings:
      base:
        INFOPLIST_FILE: FredCam/Info.plist
        PRODUCT_BUNDLE_IDENTIFIER: com.bojordan.FredCam
        MARKETING_VERSION: "1.0"
        CURRENT_PROJECT_VERSION: "1"
        DEVELOPMENT_TEAM: ""      # fill in your team ID for device builds
        SUPPORTS_MACCATALYST: false
        TARGETED_DEVICE_FAMILY: "1,2"   # 1 = iPhone, 2 = iPad
        SWIFT_VERSION: "5.9"
      debug:
        VALIDATE_EMBEDDED_BINARY_BUNDLE_IDENTIFIER: "NO"  # suppress warning during development
    dependencies:
      - package: KSPlayer
        product: KSPlayer
    postBuildScripts:
      - name: Fix Embedded Framework Bundle Identifiers
        script: |
          # ... patches libshaderc_combined's bundle ID (see Section 3)
        runOnlyWhenInstalling: false

A few things to note:

Swift Package Manager

Swift Package Manager (SPM) is Apple’s first-party dependency manager, introduced in Swift 3 and integrated directly into Xcode in Xcode 11. It’s the modern replacement for CocoaPods and Carthage, and XcodeGen integrates with it natively through the packages section of project.yml.

The version constraint from: "2.2.0" uses semantic versioning:

ConstraintMeans
from: "2.2.0"≥ 2.2.0, < 3.0.0 (next major version)
exactVersion: "2.2.0"Exactly 2.2.0, nothing else
upToNextMinor: "2.2.0"≥ 2.2.0, < 2.3.0
branch: "main"Always latest on the main branch (not reproducible)

When Xcode resolves the package, it writes a Package.resolved file (analogous to package-lock.json in npm or Cargo.lock in Rust) that pins the exact commit hash. This means from: "2.2.0" doesn’t re-resolve to a newer patch version on every build — it stays locked until you explicitly update.

Tip

On first build after xcodegen generate, Xcode fetches and compiles KSPlayer and all its transitive dependencies. KSPlayer ships pre-built XCFrameworks for its FFmpeg and Metal components, so this is mostly a download rather than a compile. Expect one to three minutes on a fast connection. Subsequent builds are instant — Xcode caches the resolved packages in ~/Library/Developer/Xcode/DerivedData.

The Post-Build Script: A Necessary Workaround

KSPlayer ships an embedded framework called libshaderc_combined.framework, which is a Metal shader compiler library from Google. Its bundle identifier is com.kintan.ksplayer.libshaderc_combined — note the underscore in libshaderc_combined.

Xcode 26 added strict validation of embedded framework bundle identifiers. Underscores are not valid in reverse-DNS bundle IDs (the spec requires only letters, digits, and hyphens). So building FredCam on Xcode 26 fails with:

error: "libshaderc_combined" is not a valid bundle identifier

The fix is to patch the framework’s Info.plist after the build phase copies it into the app bundle, replacing underscores with hyphens, and then re-sign the framework so the signature remains valid. The post-build script in project.yml does exactly this:

find "${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}" \
    -name "*.framework" -maxdepth 1 | while read framework; do
  plist="$framework/Info.plist"
  if [ -f "$plist" ]; then
    bundle_id=$(/usr/libexec/PlistBuddy -c "Print CFBundleIdentifier" "$plist")
    fixed_id=$(echo "$bundle_id" | tr '_' '-')
    if [ "$bundle_id" != "$fixed_id" ]; then
      /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $fixed_id" "$plist"
      # Re-sign so the modified framework isn't rejected at install time
      codesign --force --sign "${EXPANDED_CODE_SIGN_IDENTITY}" "$framework"
    fi
  fi
done

This kind of workaround is regrettably common in the iOS dependency ecosystem. Libraries that predate strict validation requirements often have identifiers that were never tested against the latest toolchain rules. The right long-term fix is a pull request upstream to KSPlayer (or its libshaderc packaging) to rename the bundle ID. The post-build script is the pragmatic short-term solution.

Keep an Eye On This

The VALIDATE_EMBEDDED_BINARY_BUNDLE_IDENTIFIER: "NO" debug setting and the post-build patch script are indicators that a dependency needs an upstream fix. If KSPlayer ships an update that corrects the bundle ID, both of these workarounds can be removed. Check the KSPlayer releases when updating the package — this is the kind of fix that often lands in a patch version.

Summary

XcodeGen generates FredCam.xcodeproj from project.yml. Swift Package Manager fetches KSPlayer on first build and locks the version in Package.resolved. A post-build script patches and re-signs KSPlayer’s libshaderc_combined.framework to satisfy Xcode 26’s bundle ID validation.