Building tools. Learning to build tools. Learning to build learning tools.
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 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:
deploymentTarget: iOS: "26.0"
is one line. The equivalent change in project.pbxproj touches dozens of locations..xcodeproj isn’t
in version control, two developers can’t create conflicts in it.xcodegen generate.
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.
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:
TARGETED_DEVICE_FAMILY: "1,2" makes the app a universal binary
that runs on both iPhone and iPad. Value 1 is iPhone; 2 is iPad.
Omitting iPad ("1" only) prevents the app from being installed on an iPad
even if the user wants to.
DEVELOPMENT_TEAM: "" is left empty intentionally. Each developer
fills in their own team ID locally. Committing a team ID would break builds for anyone
else cloning the repo.
VALIDATE_EMBEDDED_BINARY_BUNDLE_IDENTIFIER: "NO" suppresses
an Xcode warning in debug builds related to the KSPlayer bundle ID issue we patch in the
post-build script.
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:
| Constraint | Means |
|---|---|
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.
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.
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.
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.
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.