XCFramework: End-to-End Binary Distribution for iOS & macOS
xcframework ios mobile

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

D. Rout

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's Products/ folder (omitted by default for frameworks).
  • BUILD_LIBRARY_FOR_DISTRIBUTION=YES — emits a .swiftinterface file, 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

  1. Drag SwiftForge.xcframework into the Xcode project navigator.
  2. Target → General → Frameworks, Libraries, and Embedded Content.
  3. 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


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. 🚀

Share

Comments (1)

Join the conversation

Sign in to leave a comment on this post.

MA
Magnum3d ago

Thank you for this post. It is really helpful.