Lab 2.1 — Multi-target project setup
Duration: ~90 minutes Difficulty: Intermediate Prereqs: Phase 1 complete; Xcode 16+, Apple Developer account (free tier OK)
Goal
Build a real iOS app with multiple targets (main app + widget extension + macOS Catalyst), three build configurations (Debug / Release-Staging / Release) backed by xcconfig files, and three schemes that select the right configuration. By the end, you’ll have a project where adding a fourth environment is a 10-minute task and switching between Dev / Staging / Prod backends is a scheme picker click away.
What you’ll build
App name: LabTwoOne — a tiny note-taking app
- iOS app (the main target)
- iOS Widget Extension (shows latest note on Home Screen)
- macOS Catalyst variant
- Unit test target
Three build configurations (Debug, Release-Staging, Release), each pointing at a different “backend URL” (we’ll just print it — no real backend). Three schemes wire each configuration into a runnable build.
Steps
Step 1 — Create the base project (5 min)
- Xcode → File → New → Project → iOS → App
- Product Name:
LabTwoOne - Interface: SwiftUI, Language: Swift, Storage: None
- Save to a folder of your choice
- Run ⌘R; confirm the default app launches
Step 2 — Create the xcconfig files (15 min)
- In the project navigator, right-click the project → New Group → name it
Config - Right-click
Config→ New File → iOS → Other → Configuration Settings File - Create three files (each via the same dialog):
Shared.xcconfigDebug.xcconfigReleaseStaging.xcconfigRelease.xcconfig
Contents:
Shared.xcconfig:
SWIFT_VERSION = 6.0
IPHONEOS_DEPLOYMENT_TARGET = 17.0
MARKETING_VERSION = 1.0.0
CURRENT_PROJECT_VERSION = 1
PRODUCT_BUNDLE_IDENTIFIER = com.yourname.LabTwoOne$(BUNDLE_ID_SUFFIX)
Debug.xcconfig:
#include "Shared.xcconfig"
BUNDLE_ID_SUFFIX = .dev
API_BASE_URL = https:/$()/api.dev.example.com
SWIFT_OPTIMIZATION_LEVEL = -Onone
ReleaseStaging.xcconfig:
#include "Shared.xcconfig"
BUNDLE_ID_SUFFIX = .staging
API_BASE_URL = https:/$()/api.staging.example.com
SWIFT_OPTIMIZATION_LEVEL = -O
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) STAGING
Release.xcconfig:
#include "Shared.xcconfig"
BUNDLE_ID_SUFFIX =
API_BASE_URL = https:/$()/api.example.com
SWIFT_OPTIMIZATION_LEVEL = -O
Step 3 — Add the Release-Staging configuration (5 min)
- Click the project icon at the top of the project navigator
- Select the project (not the target) in the panel
- Info tab → Configurations section
- Click
+→ Duplicate “Release” Configuration → name itRelease-Staging
You should now see three configurations: Debug, Release, Release-Staging.
Step 4 — Wire the xcconfig files to configurations (5 min)
Still in Info → Configurations, for each configuration row, expand it and set:
| Configuration | Based on Configuration File (Project level) |
|---|---|
Debug | Debug.xcconfig |
Release-Staging | ReleaseStaging.xcconfig |
Release | Release.xcconfig |
Build (⌘B). If you see “build setting BUNDLE_ID_SUFFIX is undefined” warnings, you mistyped a key. Fix and rebuild.
Step 5 — Plumb API_BASE_URL into Info.plist (10 min)
- Open the auto-generated
Infosettings (target → Info tab — Xcode 13+ stores these in target settings, not a separateInfo.plistfile) - Add a custom key:
- Key:
APIBaseURL - Type: String
- Value:
$(API_BASE_URL)
- Key:
- Create
LabTwoOne/AppEnvironment.swift:
import Foundation
enum AppEnvironment {
static let apiBaseURL: URL = {
guard let raw = Bundle.main.object(forInfoDictionaryKey: "APIBaseURL") as? String,
let url = URL(string: raw) else {
fatalError("APIBaseURL missing or invalid in Info.plist")
}
return url
}()
static var isStaging: Bool {
#if STAGING
return true
#else
return false
#endif
}
}
- Edit
ContentView.swiftto display the values:
struct ContentView: View {
var body: some View {
VStack(spacing: 12) {
Text("LabTwoOne")
.font(.title)
Text("API: \(AppEnvironment.apiBaseURL.absoluteString)")
.font(.caption)
if AppEnvironment.isStaging {
Text("⚠️ STAGING")
.font(.caption.bold())
.foregroundStyle(.orange)
}
}
.padding()
}
}
- Build (⌘B); run (⌘R). You should see the Debug API URL.
Step 6 — Create the Staging scheme (10 min)
- Product → Scheme → Manage Schemes
- Select the existing
LabTwoOnescheme → click the duplicate icon (or right-click → Duplicate) - Name the new one
LabTwoOne (Staging) - Tick “Shared” for both schemes
- With the new scheme selected, click “Edit…”
- For each of Run, Test, Profile, Analyze, Archive:
- Set “Build Configuration” →
Release-Staging
- Set “Build Configuration” →
- Close the editor
Switch the scheme picker (top-left of Xcode toolbar) to LabTwoOne (Staging) and run. You should see the staging URL and the orange ⚠️ STAGING label.
Switch back to LabTwoOne and run — back to the dev URL, no label.
✅ Checkpoint: scheme-driven environment switching is working.
Step 7 — Add a Widget Extension target (15 min)
- File → New → Target → iOS → Widget Extension
- Product Name:
LabTwoOneWidget - Include Configuration Intent: NO (keep it simple)
- Embed in Application:
LabTwoOne - Activate the new scheme when prompted
Xcode generates a starter widget. Run the LabTwoOneWidget scheme; choose a Simulator → after build, the Home Screen appears with the widget gallery available.
Now wire the widget to the same AppEnvironment:
- Click
AppEnvironment.swiftin the project navigator - File Inspector (⌥⌘1) → Target Membership → tick
LabTwoOneWidget - The widget target now has the same xcconfig-driven environment access
Add to the widget’s LabTwoOneWidgetEntryView:
Text("API: \(AppEnvironment.apiBaseURL.host() ?? "?")")
.font(.caption2)
Run the widget scheme; confirm the host appears.
Step 8 — Add a macOS Catalyst target (10 min)
- Click the project icon → select the
LabTwoOnetarget - General tab → Supported Destinations section → click
+→ choose Mac (Designed for iPad) or Mac Catalyst (choose Mac Catalyst for a deeper Mac feel) - Confirm the prompt
- The scheme’s destination picker now shows “My Mac (Mac Catalyst)”
- Run on Mac Catalyst → confirm the app launches as a native Mac window
Step 9 — Add the .xcode-version file + Xcode pin guard (10 min)
- Open Terminal in the project root
echo "16.2" > .xcode-version(use your actual Xcode version:xcodebuild -version | head -1 | awk '{print $2}')- Create
scripts/check-xcode-version.sh:
#!/usr/bin/env bash
set -e
EXPECTED=$(cat .xcode-version)
ACTUAL=$(xcodebuild -version | head -1 | awk '{print $2}')
if [[ ! "$ACTUAL" == "$EXPECTED"* ]]; then
echo "❌ Expected Xcode $EXPECTED, found $ACTUAL"
echo " Switch with: sudo xcode-select -s /Applications/Xcode-$EXPECTED.app"
exit 1
fi
echo "✅ Xcode $ACTUAL matches expected $EXPECTED"
chmod +x scripts/check-xcode-version.sh- Run it:
./scripts/check-xcode-version.sh→ should print ✅
Add the script as a Run Script Build Phase on the main target:
- Target → Build Phases →
+→ New Run Script Phase - Drag the new phase to the top (above Compile Sources)
- Script body:
"${SRCROOT}/scripts/check-xcode-version.sh" - Add
${SRCROOT}/.xcode-versionto Input Files so Xcode caches the result
Now every build verifies the Xcode version.
Step 10 — Verify everything (10 min)
- Clean build folder (⌘⇧K)
- Run
LabTwoOne(Debug) → confirm dev URL + no staging label - Run
LabTwoOne (Staging)→ confirm staging URL + orange label - Run
LabTwoOneWidget→ confirm widget shows host - Switch destination to “My Mac (Mac Catalyst)” → run → confirm Mac launch
- Run the unit tests (⌘U) for both
LabTwoOneschemes → all should pass
Commit everything to git:
git init
git add .
git commit -m "Lab 2.1 — multi-target project with xcconfig-driven environments"
Validation checklist
-
Three build configurations exist:
Debug,Release-Staging,Release - Four xcconfig files exist and are wired at the project level
-
APIBaseURLin Info.plist resolves to a different URL per configuration -
Two shared schemes:
LabTwoOne(Debug) andLabTwoOne (Staging)(Release-Staging) - Widget extension builds and shows environment data
- Mac Catalyst destination builds and runs
-
.xcode-versionfile exists; build phase script runs on each build - All targets pass unit tests
Stretch goals
- Add an
iOS Notification Service Extensiontarget (just create it, don’t implement) — wire it toAppEnvironmentso all four targets share environment. - Add a
LabTwoOneKitSwift package (local SPM) — moveAppEnvironmentand add aNotemodel into it. All targets import the package instead of duplicating files via target membership. - Add a CI workflow (
.github/workflows/ci.yml) that runs./scripts/check-xcode-version.shand thenxcodebuild teston both schemes.
What you’ve internalized
- The mental model of project → target → scheme → configuration
- How xcconfig files externalize build settings and survive merge conflicts
- The Info.plist bridge from build settings to runtime Swift code
- Multi-target target membership for sharing source files
- The Xcode version pin pattern that scales to a real team