
XCFramework: End-to-End Binary Distribution for iOS & macOS

D. Rout
March 5, 2026 12 min read
On this page
Stack: Swift 5.9 · Xcode 15 · iOS 15+ · macOS 12+
Difficulty: Intermediate
Time: ~60 minutes
What is an XCFramework?
An XCFramework is a distributable binary format introduced at WWDC 2019. It bundles compiled frameworks for multiple platforms into one artifact that Xcode slices and links correctly at build time.
The old approach — fat binaries via lipo — broke with Apple Silicon, because both the simulator and the device now share the arm64 architecture. A lipo fat binary cannot contain two slices of the same architecture. XCFramework solves this cleanly:
SwiftForge.xcframework/
├── Info.plist
├── ios-arm64/
│ └── SwiftForge.framework/
├── ios-arm64_x86_64-simulator/
│ └── SwiftForge.framework/
└── macos-arm64_x86_64/
└── SwiftForge.framework/
What We're Building — SwiftForge
SwiftForge is a utility library with five focused modules:
| Module | Responsibilities |
|---|---|
StringKit |
Trimming, slug generation, truncation, masking |
DateKit |
Relative formatting, ISO parsing, calendar arithmetic |
ValidatorKit |
Email, URL, phone, credit card (Luhn), password strength |
HashKit |
SHA-256, HMAC-SHA256, random bytes (wraps CryptoKit) |
NetworkKit |
Fluent async/await HTTP request builder |
All modules live in a single framework target. import SwiftForge exposes them all.
Project Setup
In Xcode: File → New → Project → Framework
Product Name: SwiftForge
Language: Swift
Bundle ID: com.yourcompany.SwiftForge
Create these source files under SwiftForge/:
SwiftForge/
├── StringKit.swift
├── DateKit.swift
├── ValidatorKit.swift
├── CryptoKit+Extensions.swift
├── NetworkKit.swift
└── SwiftForge.swift
Writing the Library
StringKit
// StringKit.swift
import Foundation
public enum StringKit {
public static func trimmed(_ string: String) -> String {
string.trimmingCharacters(in: .whitespacesAndNewlines)
}
public static func normalised(_ string: String) -> String {
string.components(separatedBy: .whitespaces)
.filter { !$0.isEmpty }
.joined(separator: " ")
}
/// "Hello World! Swift" → "hello-world-swift"
public static func slug(from string: String) -> String {
var s = string.lowercased()
s = s.applyingTransform(.toLatin, reverse: false) ?? s
s = s.applyingTransform(.stripCombiningMarks, reverse: false) ?? s
let allowed = CharacterSet.alphanumerics
s = s.unicodeScalars.map { allowed.contains($0) ? String($0) : "-" }.joined()
while s.contains("--") { s = s.replacingOccurrences(of: "--", with: "-") }
return s.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
}
public static func truncated(_ string: String, maxLength: Int, ellipsis: String = "…") -> String {
guard string.count > maxLength else { return string }
return String(string.prefix(maxLength)) + ellipsis
}
/// mask("4111111111111111", visible: 4) → "••••••••••••1111"
public static func mask(_ string: String, visible: Int, character: Character = "•") -> String {
guard string.count > visible else { return string }
return String(repeating: character, count: string.count - visible) + string.suffix(visible)
}
public static func wordCount(in string: String) -> Int {
string.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }.count
}
public static func isBlank(_ string: String) -> Bool { trimmed(string).isEmpty }
}
DateKit
// DateKit.swift
import Foundation
public enum DateKit {
/// Returns "3 minutes ago", "in 2 days", etc.
public static func relativeDescription(of date: Date, relativeTo reference: Date = Date(), locale: Locale = .current) -> String {
let f = RelativeDateTimeFormatter()
f.locale = locale; f.unitsStyle = .full
return f.localizedString(for: date, relativeTo: reference)
}
public static func formatted(_ date: Date, format: String, timeZone: TimeZone = .current) -> String {
let f = DateFormatter()
f.dateFormat = format; f.timeZone = timeZone
return f.string(from: date)
}
/// Parses ISO 8601 with or without fractional seconds.
public static func parseISO8601(_ string: String) -> Date? {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let d = f.date(from: string) { return d }
f.formatOptions = [.withInternetDateTime]
return f.date(from: string)
}
public static func adding(
years: Int = 0, months: Int = 0, days: Int = 0,
hours: Int = 0, minutes: Int = 0, seconds: Int = 0,
to date: Date, calendar: Calendar = .current
) -> Date {
var c = DateComponents()
c.year = years; c.month = months; c.day = days
c.hour = hours; c.minute = minutes; c.second = seconds
return calendar.date(byAdding: c, to: date) ?? date
}
public static func daysBetween(_ start: Date, and end: Date, calendar: Calendar = .current) -> Int {
let s = calendar.startOfDay(for: start)
let e = calendar.startOfDay(for: end)
return calendar.dateComponents([.day], from: s, to: e).day ?? 0
}
}
ValidatorKit
// ValidatorKit.swift
import Foundation
public enum ValidatorKit {
public static func isValidEmail(_ email: String) -> Bool {
matches(email, pattern: #"^[A-Z0-9a-z._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$"#)
}
public static func isValidURL(_ urlString: String) -> Bool {
guard let url = URL(string: urlString),
let scheme = url.scheme?.lowercased(),
["http", "https"].contains(scheme),
url.host != nil else { return false }
return true
}
/// Validates E.164 international phone numbers.
public static func isValidPhone(_ phone: String) -> Bool {
let stripped = phone.filter { $0.isNumber || $0 == "+" }
return matches(stripped, pattern: #"^\+?[1-9]\d{6,14}$"#)
}
/// Luhn algorithm credit card validation.
public static func isValidCreditCard(_ number: String) -> Bool {
let digits = number.filter(\.isNumber)
guard (13...19).contains(digits.count) else { return false }
let reversed = digits.reversed().compactMap { Int(String($0)) }
let sum = reversed.enumerated().reduce(0) { acc, pair in
let (i, digit) = pair
if i % 2 == 1 { let d = digit * 2; return acc + (d > 9 ? d - 9 : d) }
return acc + digit
}
return sum % 10 == 0
}
public static func isValidUUID(_ string: String) -> Bool { UUID(uuidString: string) != nil }
public enum PasswordStrength: Int, CustomStringConvertible {
case veryWeak, weak, fair, strong, veryStrong
public var description: String {
["Very Weak", "Weak", "Fair", "Strong", "Very Strong"][rawValue]
}
}
public static func passwordStrength(_ password: String) -> PasswordStrength {
var score = 0
if password.count >= 8 { score += 1 }
if password.count >= 12 { score += 1 }
if password.rangeOfCharacter(from: .uppercaseLetters) != nil { score += 1 }
if password.rangeOfCharacter(from: .decimalDigits) != nil { score += 1 }
if password.rangeOfCharacter(from: .punctuationCharacters) != nil ||
password.rangeOfCharacter(from: .symbols) != nil { score += 1 }
return PasswordStrength(rawValue: min(score, 4)) ?? .veryWeak
}
private static func matches(_ string: String, pattern: String) -> Bool {
(try? NSRegularExpression(pattern: pattern))
.map { $0.firstMatch(in: string, range: NSRange(string.startIndex..., in: string)) != nil }
?? false
}
}
HashKit
// CryptoKit+Extensions.swift
import Foundation
import CryptoKit
public enum HashKit {
public static func sha256(string: String) -> String { sha256(data: Data(string.utf8)) }
public static func sha256(data: Data) -> String {
SHA256.hash(data: data).compactMap { String(format: "%02x", $0) }.joined()
}
public static func hmacSHA256(message: String, key: String) -> String {
HMAC<SHA256>.authenticationCode(
for: Data(message.utf8),
using: SymmetricKey(data: Data(key.utf8))
).compactMap { String(format: "%02x", $0) }.joined()
}
public static func verifySHA256(string: String, against hash: String) -> Bool {
sha256(string: string) == hash.lowercased()
}
/// Generates `byteCount * 2` cryptographically-random hex characters.
public static func randomHex(byteCount: Int = 16) -> String {
var bytes = [UInt8](repeating: 0, count: byteCount)
_ = SecRandomCopyBytes(kSecRandomDefault, byteCount, &bytes)
return bytes.map { String(format: "%02x", $0) }.joined()
}
}
NetworkKit
// NetworkKit.swift
import Foundation
public enum NetworkError: Error, LocalizedError {
case invalidURL(String)
case httpError(statusCode: Int, data: Data)
case decodingFailed(Error)
public var errorDescription: String? {
switch self {
case .invalidURL(let u): return "Invalid URL: \(u)"
case .httpError(let code, _): return "HTTP \(code)"
case .decodingFailed(let e): return e.localizedDescription
}
}
}
public struct NetworkRequest {
public enum Method: String { case get = "GET", post = "POST", put = "PUT", delete = "DELETE" }
public let url: URL
public var method: Method = .get
public var headers: [String: String] = [:]
public var body: Data? = nil
public var timeoutInterval: TimeInterval = 30
public init?(urlString: String) {
guard let url = URL(string: urlString) else { return nil }
self.url = url
}
public func method(_ m: Method) -> Self { var c = self; c.method = m; return c }
public func header(_ f: String, value v: String) -> Self { var c = self; c.headers[f] = v; return c }
public func timeout(_ t: TimeInterval) -> Self { var c = self; c.timeoutInterval=t; return c }
public func jsonBody<T: Encodable>(_ value: T, encoder: JSONEncoder = .init()) throws -> Self {
var c = self
c.body = try encoder.encode(value)
c.headers["Content-Type"] = "application/json"
return c
}
public func data(session: URLSession = .shared) async throws -> Data {
var req = URLRequest(url: url)
req.httpMethod = method.rawValue
req.timeoutInterval = timeoutInterval
req.httpBody = body
headers.forEach { req.setValue($1, forHTTPHeaderField: $0) }
let (data, response) = try await session.data(for: req)
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
throw NetworkError.httpError(statusCode: http.statusCode, data: data)
}
return data
}
public func decoded<T: Decodable>(as type: T.Type = T.self, decoder: JSONDecoder = .init()) async throws -> T {
do { return try decoder.decode(type, from: try await data()) }
catch { throw NetworkError.decodingFailed(error) }
}
}
Public Façade
// SwiftForge.swift
public enum SwiftForge {
public static let version = "1.0.0"
}
// All five modules live in the same target — import SwiftForge exposes them all.
Unit Testing
Add a Unit Test target (SwiftForgeTests) linked to the framework.
import XCTest
@testable import SwiftForge
final class StringKitTests: XCTestCase {
func test_slug() { XCTAssertEqual(StringKit.slug(from: "Hello World!"), "hello-world") }
func test_mask() { XCTAssertEqual(StringKit.mask("4111111111111111", visible: 4), "••••••••••••1111") }
func test_truncated() { XCTAssertEqual(StringKit.truncated("Hello World", maxLength: 5), "Hello…") }
}
final class ValidatorKitTests: XCTestCase {
func test_email_valid() { XCTAssertTrue(ValidatorKit.isValidEmail("user@example.com")) }
func test_email_invalid() { XCTAssertFalse(ValidatorKit.isValidEmail("not-an-email")) }
func test_luhn_pass() { XCTAssertTrue(ValidatorKit.isValidCreditCard("4111 1111 1111 1111")) }
func test_luhn_fail() { XCTAssertFalse(ValidatorKit.isValidCreditCard("1234 5678 9012 3456")) }
func test_password() { XCTAssertGreaterThanOrEqual(ValidatorKit.passwordStrength("X7!mQz#2vLpW").rawValue, 3) }
}
final class HashKitTests: XCTestCase {
func test_sha256_empty() {
// SHA-256("") is a well-known constant
XCTAssertEqual(HashKit.sha256(string: ""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
}
func test_verify_roundtrip() {
let h = HashKit.sha256(string: "SwiftForge")
XCTAssertTrue(HashKit.verifySHA256(string: "SwiftForge", against: h))
}
func test_randomHex_length() { XCTAssertEqual(HashKit.randomHex(byteCount: 16).count, 32) }
}
Run from Terminal:
xcodebuild test \
-scheme SwiftForge \
-destination 'platform=iOS Simulator,name=iPhone 15 Pro'
Building the XCFramework
We need three platform archives:
| Platform | SDK | Architectures |
|---|---|---|
| iOS device | iphoneos |
arm64 |
| iOS Simulator | iphonesimulator |
arm64 + x86_64 |
| macOS | macosx |
arm64 + x86_64 |
Two build settings are critical:
SKIP_INSTALL=NO— copies the framework into the archive'sProducts/folder (omitted by default for frameworks).BUILD_LIBRARY_FOR_DISTRIBUTION=YES— emits a.swiftinterfacefile, decoupling the binary from a specific Swift toolchain version.
SCHEME="SwiftForge"
PROJECT="SwiftForge.xcodeproj"
ARCHIVES="./archives"
# iOS device
xcodebuild archive \
-project "$PROJECT" -scheme "$SCHEME" \
-destination "generic/platform=iOS" \
-archivePath "$ARCHIVES/SwiftForge-iOS.xcarchive" \
SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES
# iOS Simulator
xcodebuild archive \
-project "$PROJECT" -scheme "$SCHEME" \
-destination "generic/platform=iOS Simulator" \
-archivePath "$ARCHIVES/SwiftForge-iOS-Simulator.xcarchive" \
SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES
# macOS
xcodebuild archive \
-project "$PROJECT" -scheme "$SCHEME" \
-destination "generic/platform=macOS" \
-archivePath "$ARCHIVES/SwiftForge-macOS.xcarchive" \
SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES
# Assemble
xcodebuild -create-xcframework \
-framework "$ARCHIVES/SwiftForge-iOS.xcarchive/Products/Library/Frameworks/SwiftForge.framework" \
-framework "$ARCHIVES/SwiftForge-iOS-Simulator.xcarchive/Products/Library/Frameworks/SwiftForge.framework" \
-framework "$ARCHIVES/SwiftForge-macOS.xcarchive/Products/Library/Frameworks/SwiftForge.framework" \
-output "./SwiftForge.xcframework"
Verify the output:
SwiftForge.xcframework/
├── Info.plist
├── ios-arm64/
│ └── SwiftForge.framework/
│ └── Modules/SwiftForge.swiftmodule/
│ └── arm64-apple-ios.swiftinterface ← forward-compat key
├── ios-arm64_x86_64-simulator/
│ └── SwiftForge.framework/
└── macos-arm64_x86_64/
└── SwiftForge.framework/
Distributing via SPM
Create a Package.swift in a separate Git repository:
// Package.swift (swift-tools-version: 5.9)
import PackageDescription
let package = Package(
name: "SwiftForge",
platforms: [.iOS(.v15), .macOS(.v12)],
products: [
.library(name: "SwiftForge", targets: ["SwiftForge"])
],
targets: [
// Local path — great for development
.binaryTarget(name: "SwiftForge", path: "SwiftForge.xcframework")
// Remote release — use this for production:
// .binaryTarget(
// name: "SwiftForge",
// url: "https://github.com/yourorg/SwiftForge/releases/download/1.0.0/SwiftForge.xcframework.zip",
// checksum: "paste_checksum_here"
// )
]
)
Compute the checksum for a remote release:
zip -r SwiftForge-1.0.0.xcframework.zip SwiftForge.xcframework
swift package compute-checksum SwiftForge-1.0.0.xcframework.zip
# → abc123def456… paste into Package.swift
Consuming the Library
Via SPM
File → Add Package Dependencies…
URL: https://github.com/yourorg/SwiftForge-Package
Up to Next Major: 1.0.0
import SwiftForge
// Strings
let slug = StringKit.slug(from: "My Blog Post Title!") // "my-blog-post-title"
let masked = StringKit.mask("4111111111111111", visible: 4) // "••••••••••••1111"
// Dates
let ago = DateKit.relativeDescription(of: Date().addingTimeInterval(-120)) // "2 minutes ago"
let nextWeek = DateKit.adding(days: 7, to: Date())
// Validation
ValidatorKit.isValidEmail("hello@example.com") // true
ValidatorKit.isValidCreditCard("4111111111111111") // true
// Hashing
let fingerprint = HashKit.sha256(string: "user@example.com")
let token = HashKit.randomHex(byteCount: 32) // 64-char hex string
// Networking
struct Post: Decodable { let id: Int; let title: String }
Task {
let post = try await NetworkRequest(urlString: "https://jsonplaceholder.typicode.com/posts/1")?
.decoded(as: Post.self)
print(post?.title ?? "")
}
Via Drag-and-Drop
- Drag
SwiftForge.xcframeworkinto the Xcode project navigator. - Target → General → Frameworks, Libraries, and Embedded Content.
- Set it to Embed & Sign.
Automation Script
#!/usr/bin/env bash
# build_xcframework.sh — usage: ./build_xcframework.sh 1.0.0
set -euo pipefail
SCHEME="SwiftForge"
PROJECT="SwiftForge.xcodeproj"
VERSION="${1:-dev}"
ARCHIVES="./build/archives"
OUT="./build/output"
rm -rf ./build && mkdir -p "$ARCHIVES" "$OUT"
SETTINGS=(SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES)
archive() {
echo "› Archiving $2…"
xcodebuild archive -project "$PROJECT" -scheme "$SCHEME" \
-destination "$1" -archivePath "$ARCHIVES/$2.xcarchive" "${SETTINGS[@]}"
}
archive "generic/platform=iOS" "SwiftForge-iOS"
archive "generic/platform=iOS Simulator" "SwiftForge-iOS-Simulator"
archive "generic/platform=macOS" "SwiftForge-macOS"
echo "› Creating XCFramework…"
xcodebuild -create-xcframework \
-framework "$ARCHIVES/SwiftForge-iOS.xcarchive/Products/Library/Frameworks/SwiftForge.framework" \
-framework "$ARCHIVES/SwiftForge-iOS-Simulator.xcarchive/Products/Library/Frameworks/SwiftForge.framework" \
-framework "$ARCHIVES/SwiftForge-macOS.xcarchive/Products/Library/Frameworks/SwiftForge.framework" \
-output "$OUT/SwiftForge.xcframework"
echo "› Zipping…"
cd "$OUT"
zip -r "SwiftForge-${VERSION}.xcframework.zip" SwiftForge.xcframework
CHECKSUM=$(swift package compute-checksum "SwiftForge-${VERSION}.xcframework.zip")
echo "✅ Done! Checksum: $CHECKSUM"
echo "url: \"…/releases/download/${VERSION}/SwiftForge-${VERSION}.xcframework.zip\","
echo "checksum: \"$CHECKSUM\""
Common Pitfalls
Module compiled with Swift X cannot be imported by Swift Y — forgot BUILD_LIBRARY_FOR_DISTRIBUTION=YES. With it enabled, Xcode emits a .swiftinterface file any compatible compiler can parse, breaking the tight coupling to a single toolchain version.
image not found at runtime — the xcframework was added but set to Do Not Embed. Change it to Embed & Sign under Target → General → Frameworks, Libraries, and Embedded Content.
Archive has no framework — the default for framework targets is SKIP_INSTALL=YES, which excludes them from Products/. Override with SKIP_INSTALL=NO when archiving.
Checksum mismatch — the checksum in Package.swift must match the zip file, not the directory. Always compute with swift package compute-checksum <file>.zip.
Further Reading
- WWDC19 Session 416 — Binary Frameworks in Swift (Apple Developer)
- Creating a Multi-Platform Binary Framework Bundle (Apple Docs)
- Swift Package Manager Documentation (swift.org)
- Apple CryptoKit Documentation
- Swift Package Index — submit your package here
- WWDC22 — Meet Swift Package Plugins (Apple Developer)
Wrapping Up
You've built SwiftForge end-to-end — five cohesive utility modules, compiled into an XCFramework covering iOS, iOS Simulator, and macOS, distributed through Swift Package Manager. The key rules: always set BUILD_LIBRARY_FOR_DISTRIBUTION=YES, archive each platform separately, and compute the SPM checksum from the zip file. Automate with the shell script and your releases will be repeatable every time. 🚀
Read next
Comments (1)
Join the conversation
Sign in to leave a comment on this post.
Thank you for this post. It is really helpful.