Chris' Blog.

My occasional thoughts on iOS development, developers careers, trying to make an income from the App Store, and updates on life in general.

RSA and EC

I recently had to perform RSA encryption/decryption in Swift for an iOS app. Unfortunately I couldn’t find any examples for how to do this without bringing in a library dependency, which I really wanted to avoid to keep the app efficient and because I can’t reasonably vet their code. And besides: Apple already provides the Security.framework which handles RSA, so it’d be a shame not to use it. It doesn’t bridge nicely to Swift however, so here’s a wrapper I wrote to help use it. I couldn’t find any good examples on the internet so I hope this helps someone:

import Security

// See: https://developer.apple.com/documentation/security/certificate_key_and_trust_services/keys
extension SecKey {
    
    enum KeyType {
        case rsa
        case ellipticCurve
        var secAttrKeyTypeValue: CFString {
            switch self {
            case .rsa:
                return kSecAttrKeyTypeRSA
            case .ellipticCurve:
                return kSecAttrKeyTypeECSECPrimeRandom
            }
        }
    }
    
    /// Creates a random key.
    /// Elliptic curve bits options are: 192, 256, 384, or 521.
    static func createRandomKey(type: KeyType, bits: Int) throws -> SecKey {
        var error: Unmanaged<CFError>?
        let keyO = SecKeyCreateRandomKey([
            kSecAttrKeyType: type.secAttrKeyTypeValue,
            kSecAttrKeySizeInBits: NSNumber(integerLiteral: bits),
        ] as CFDictionary, &error)
        // See here for apple's sample code for memory-managing returned errors
        // from the Security framework:
        // https://developer.apple.com/documentation/security/certificate_key_and_trust_services/keys/storing_keys_as_data
        if let error = error?.takeRetainedValue() { throw error }
        guard let key = keyO else { throw MyErrors.nilKey }
        return key
    }
    
    /// Gets the public key from a key pair.
    func publicKey() throws -> SecKey {
        let publicKeyO = SecKeyCopyPublicKey(self)
        guard let publicKey = publicKeyO else { throw MyErrors.nilPublicKey }
        return publicKey
    }
    
    /// Exports a key.
    /// RSA keys are returned in PKCS #1 / DER / ASN.1 format.
    /// EC keys are returned in ANSI X9.63 format.
    func externalRepresentation() throws -> Data {
        var error: Unmanaged<CFError>?
        let dataO = SecKeyCopyExternalRepresentation(self, &error)
        if let error = error?.takeRetainedValue() { throw error }
        guard let data = dataO else { throw MyErrors.nilExternalRepresentation }
        return data as Data
    }
    
    // Self must be the public key returned by publicKey().
    // Algorithm should be SecKeyAlgorithm.rsaEncryption* or .eciesEncryption*
    func encrypt(algorithm: SecKeyAlgorithm, plaintext: Data) throws -> Data {
        var error: Unmanaged<CFError>?
        let ciphertextO = SecKeyCreateEncryptedData(self, algorithm,
            plaintext as CFData, &error)
        if let error = error?.takeRetainedValue() { throw error }
        guard let ciphertext = ciphertextO else { throw MyErrors.nilCiphertext }
        return ciphertext as Data
    }
    
    // Self must be the private/public key pair returned by createRandomKey().
    // Algorithm should be SecKeyAlgorithm.rsaEncryption* or .eciesEncryption*
    func decrypt(algorithm: SecKeyAlgorithm, ciphertext: Data) throws -> Data {
        var error: Unmanaged<CFError>?
        let plaintextO = SecKeyCreateDecryptedData(self, algorithm,
            ciphertext as CFData, &error)
        if let error = error?.takeRetainedValue() { throw error }
        guard let plaintext = plaintextO else { throw MyErrors.nilPlaintext }
        return plaintext as Data
    }

    enum MyErrors: Error {
        case nilKey
        case nilPublicKey
        case nilExternalRepresentation
        case nilCiphertext
        case nilPlaintext
    }

}

How to use it

It boils down to calling:

  • createRandomKey(…) to get a key pair.
  • publicKey() to get just the public key.
  • externalRepresentation() to get the public key as data that you can send to eg your backend.
  • encrypt(…) to encrypt something with the public key.
  • decrypt(…) to decrypt something with the key pair.

Here’s some demo code for using the above:

// Elliptic curve test.
do {
    let keyPair = try SecKey.createRandomKey(
        type: .ellipticCurve,
        bits: 384)
    let publicKey = try keyPair.publicKey()
    let plain = "Chuck Norris has counted to infinity. Twice.".data(using: .utf8)!
    let ciphertext = try publicKey.encrypt(
        algorithm: .eciesEncryptionStandardVariableIVX963SHA256AESGCM,
        plaintext: plain)
    let plainAgain = try keyPair.decrypt(
        algorithm: .eciesEncryptionStandardVariableIVX963SHA256AESGCM,
        ciphertext: ciphertext)
    let string = String(data: plainAgain, encoding: .utf8)! // Don't force-unwrap in real code.
    print("EC decrypted: " + string)
} catch {
    print("Error: \(error)")
}

// RSA test.
do {
    let keyPair = try SecKey.createRandomKey(
        type: .rsa,
        bits: 2048)
    let publicKey = try keyPair.publicKey()
    let plain = "Chuck Norris can set ants on fire with a magnifying glass. At night.".data(using: .utf8)!
    let ciphertext = try publicKey.encrypt(
        algorithm: .rsaEncryptionOAEPSHA1AESGCM,
        plaintext: plain)
    let plainAgain = try keyPair.decrypt(
        algorithm: .rsaEncryptionOAEPSHA1AESGCM,
        ciphertext: ciphertext)
    let string = String(data: plainAgain, encoding: .utf8)! // Don't force-unwrap in real code.
    print("RSA decrypted: " + string)
} catch {
    print("Error: \(error)")
}

Algorithm selection

If you’re integrating with an existing system, you’ll simply have to match their key type and algorithm. But if it’s up to you, here’s some tips for how to choose:

Read Apple’s headers! In Xcode, command-click on SecKeyAlgorithm and read the comments above it. Many algorithms will be noted as legacy, so avoid if possible. And some do not support arbitrary-length data which might be a problem for you. So choose wisely.

And a reasonable option in my opinion is:

  • Key type: .ellipticCurve
  • Key size: 384 bits
  • Algorithm: .eciesEncryptionCofactorVariableIVX963SHA384AESGCM

Note

I’m not a qualified cryptographer, this information carries no warranty, you should talk to a qualified security professional about the implications of when to use this appropriately!

Thanks for reading, I hope this is helpful, God bless :)

Photo by David Clode on Unsplash


Async await

So Async and Await has finally come to Swift, with the release of Xcode 13! Finally! Here’s some examples for practically using it with an existing codebase, interfacing with normal (synchronous) functions.

How can I wrap a callback function with an async one?

Say you have a function with a completion Result<T, Error> callback, you can wrap it to convert it to async/await code using withCheckedThrowingContinuation like so:

// Convert a callback-style function to async await.
func getUser(name: String) async throws -> String {
    try await withCheckedThrowingContinuation {
        continuation in
        getUserViaCallback(name: name, completion: {
            result in
            continuation.resume(with: result)
        })
    }
}

A Result.failure will thus become a thrown error, and a Result.success will be a returned String.

For reference, the function with the completion handler that we’re wrapping above is:

// Completion callback (non async/await) network function.
func getUserViaCallback(name: String, completion:
    @escaping (Result<String, Error>) -> ()) {
        
    enum MyErrors: Error {
        case encoding
        case badUrl
        case noData
        case badData
    }

    guard let safeName = name.addingPercentEncoding(
        withAllowedCharacters: .urlQueryAllowed) else {
        completion(.failure(MyErrors.encoding))
        return
    }
    let urlStr = "https://hacker-news.firebaseio.com/v0/user/\(safeName).json"
    guard let url = URL(string: urlStr) else {
        completion(.failure(MyErrors.badUrl))
        return
    }
    URLSession.shared.dataTask(with: url, completionHandler: {
        data, response, error in
        if let error = error {
            completion(.failure(error))
        }
        guard let data = data else {
            completion(.failure(MyErrors.noData))
            return
        }
        guard let string = String(data: data, encoding: .utf8) else {
            completion(.failure(MyErrors.badData))
            return
        }
        let name = String(string.prefix(20)) // Silly example :)
        completion(.success(name))
    }).resume()
}

How can I orchestrate multiple async calls?

This is where async/await shines: Orchestrating multiple calls. This is a life-saver if you want to make readable code when you have a tricky backend that requires multiple calls to perform one ‘thing’:

// Example of how you can use await to elegantly orchestrate multiple network calls.
func getUsers() async throws -> [String] {
    let a = try await getUser(name: "patio11")
    let b = try await getUser(name: "dang")
    let c = try await getUser(name: "pg")
    let d = try await getUser(name: "llambda")
    return [a, b, c, d]
}

How can I call an async function from a normal (synchronous) one?

Async/await is viral: Any async function must be called by another async one, all the way up the chain. Which obviously isn’t going to be possible with a UIKit app. The ‘trapdoor’ to exit this requirement is the async function. The example below shows how you can use this to convert an async function into one with a familiar Result completion callback. It would be the responsibility of the caller to dispatch to the Main queue to update the UI:

// Call an async function from normal sync code.
// The completion is on an undefined thread/queue.
func getUsersWithCallback(completion: @escaping (Result<[String], Error>) -> ()) {
    async {
        do {
            let users = try await getUsers()
            completion(.success(users))
        } catch {
            completion(.failure(error))
        }
    }
}

What thread does it run on?

Short answer: a GCD Global queue.

Long answer: Apple’s documentation is pretty vague when it comes to which thread your async functions run on. If you’re creating a server app, maybe this isn’t an issue. But if you’re writing a UIKit app, this is very important: You need to be on the main thread to do any UI updates, of course!

When calling an async func from a normal synchronous one, you use this helper:

async(priority: Task.Priority? = nil, operation: () async -> T)

The priorities are basically the same as the GCD ones you’d specify when asking for a global queue:

DispatchQueue.global(qos: DispatchQoS.QoSClass)

Thus I assume async functions simply run on the GCD global queue of whatever priority/QOS you specify (or you can leave it nil to get the default background queue).

What version of iOS does it need?

As of writing (July 2021), your app will require iOS 15 to use async+await. Bummer. However: this is listed as an ‘issue’ in the Xcode beta release notes, which people are taking as a hint that Apple is hard at work resolving this so that your app will not require iOS 15 to use it, thus making it an option for a shipping app. Fingers crossed that once Xcode 13 comes out of beta, async/await will have some form of backwards compatibility.

Thanks for reading, I hope this is helpful, God bless :)

Photo by freezydreamin on Unsplash


Progen

Every now and again I find myself on a project where we need to white-label the app, eg: from one generic codebase, generate several apps for different newspapers in different states; each having their own branding/colours/fonts, different backend endpoints, some slight differences in the code, but almost identical features. For these projects I like to use an Xcode project generator to keep things manageable.

There are also other reasons you might like to use a project generator: Suppose you want to rule out the possibility of Git conflicts in your Pbxproj file in a large team, etc.

I’ve used ruby libraries in the past to generate my project files to achieve this, however I’ve found that when you encounter anything they don’t support (eg SPM) you’re stuck, and there’s just as much effort spent learning how to use these libraries as there would be to simply write my own, because the project format isn’t that difficult anyway. So now I’ve created my own generator. Here it is, i’m open sourcing it! It’s listed here as code for you to copy and paste, rather than as a Git repo, because you’re definitely going to want to extend it.

Note that this was written in 2021 for Xcode 12.4, in future years this may need a little tweaking. This runs as a ‘swift script’ and thus has a few workarounds for Swift’s oddities when run in that way.

For this to work, your project root folder should be set up thus:

  • Newspapers.xcodeproj (as per the example code)
  • ProjectGenerator.swift
  • Source folder
  • UnitTests folder
  • UITests folder
  • Source/WhiteLabels/WhiteLabelA/Info.plist
  • Source/WhiteLabels/WhiteLabelB/Info.plist
  • Source/WhiteLabels/WhiteLabelC/Info.plist

The key feature is: As the project generator scans the folder structure, if it encounters a folder that is named the same as one of the white labels, any files/folders underneath that folder are only added to that whitelabel’s target. Thus you can have different assets/code per whitelabel by simply putting it in appropriately named folders.

Important things to note as you read the below code:

  • Look for the whiteLabels declaration: This is where you’d declare your different white-labelled apps you want to generate.
  • configPackages is where you list your Swift Package Manager (SPM) dependencies.
  • You’ll probably have certain files you don’t want included as assets: search for .graphql to see an example of files that are skipped.
  • Any files with test in their name in the Source folder are added to the unit test target, not the normal target.
  • You will want to change DEVELOPMENT_TEAM to your team ID.
  • Other build settings are largely the same as Xcode 12.5 defaults. Customise as you see fit.
  • TARGETED_DEVICE_FAMILY is set to iphone only, you might want to change this to include the iPad.
  • Generally whenever I need to add anything that my project generator does not support, I make a change in Xcode then view the diff in Git, to see where that is added, and add it to my generator as appropriate. This code is as simple as possible to hopefully make that easy for you.
  • There is an example shell script build phase implemented here for Apollo: search for apolloPhase to see how you can add your own shell scripts.
  • I also have a very handy build phase here that auto-generates the build numbers for release builds, search for buildNumberPhase to see it.
  • To run this and generate your pbxproj, simply place this script in your project root (alongside the *.xcodeproj) and run ./ProjectGenerator.swift whenever appropriate, eg you’ve added files.

ProjectGenerator.swift

#!/usr/bin/env xcrun swift

import Cocoa // import Foundation causes the error 'JIT session error: Symbols not found' for some reason.

// This file would ideally be in separate .swift files, however swift does not allow for that when using it for scripting.
// Thus the 'files' are split up with large comments.

// Configuration is the second section after IDs, this is a swift limitation.

print("[ProjectGenerator] Running")

/**************** ID generation ****************/

// This ID stuff has to be on top, otherwise swift crashes.

typealias ID = String

// IDs are 24 hex chars, eg 96 bits.
// UInt64 uses 16 chars, so the first 8 are just hardcoded.
enum IDPrefix: UInt64 {
    case fun = 0xBEEF0000
    case project
    case target
    case group
    case file
    case buildFile
    case configurationList
    case buildConfiguration
    case buildPhase
    case targetDependency
    case containerItemProxy
    case package
    case packageProduct
}

// Keep track of which IDs have been used.
var usedIDs: [IDPrefix: UInt64] = [:]

extension ID {
    static func next(_ prefix: IDPrefix) -> ID {
        let lastValue = usedIDs[prefix] ?? 0
        let thisValue = lastValue + 1
        usedIDs[prefix] = thisValue
        return String(format: "%08X%016X", prefix.rawValue, thisValue)
    }
}

/********************* Configuration *********************/

// Customise your WLs here!
let whiteLabels: [WhiteLabel] = [
    WhiteLabel(name: "SydneyNewspaper",
            plist: "Source/WhiteLabels/SydneyNewspaper/Info.plist",
            bundleId: "au.com.splinter.newspapers.sydney"),
    WhiteLabel(name: "MelbourneNewspaper",
            plist: "Source/WhiteLabels/MelbourneNewspaper/Info.plist",
            bundleId: "au.com.splinter.newspapers.melbourne"),
    WhiteLabel(name: "QueenslandNewspaper",
            plist: "Source/WhiteLabels/QueenslandNewspaper/Info.plist",
            bundleId: "au.com.splinter.newspapers.queensland"),
]

let projectRootFolder = "./" // Must end with a slash for URL(relativeTo) to work.
let xcodeprojPath = "Newspapers.xcodeproj"
let pbxprojPath = "Newspapers.xcodeproj/project.pbxproj"
let sourceFolder = "Source"
let unitTestsFolder = "UnitTests"
let uiTestsFolder = "UITests"

// This is structured slightly simpler to the package/product in the models below which has to match Xcode.
struct ConfigPackage {
    let id: ID // Needed up-front to simplify relationships.
    let url: String
    let requirement: [String: String]
    let products: [String]
}

// This is where you list the SPM packages you need.
let configPackages: [ConfigPackage] = [
    ConfigPackage(id: .next(.package),
                url: "[email protected]:apollographql/apollo-ios.git",
                requirement: [
                    "kind": "upToNextMinorVersion",
                    "minimumVersion": "0.42.0",
                ],
                products: ["Apollo", "ApolloCore", "ApolloWebSocket"]),
]

/********************* Models *********************/

struct WhiteLabel {
    let name: String
    let plist: String
    let bundleId: String
}

// Hierarchy:
// Project
//     ConfigurationList
//         BuildConfiguration
//     Group
//         FileReference
//     Target
//         ConfigurationList
//             BuildConfiguration
//         TargetDependency
//             ContainerItemProxy
//         BuildPhase
//             BuildFile

struct Project {
    let id: ID
    let attributes: ProjectAttributes
    let buildConfigurationList: ConfigurationList
    let compatibilityVersion: String
    let developmentRegion: String
    let hasScannedForEncodings: Bool
    let knownRegions: [String]
    let mainGroup: Group
    let productRefGroup: ID
    let projectDirPath: String
    let projectRoot: String
    let targets: [Target]
    let packages: [Package]
}

// A Swift Package Manager package.
struct Package {
    let id: ID
    let repositoryURL: String
    let requirement: [String: String]
}

// A SPM package will have 1+ of these.
struct PackageProduct {
    let id: ID
    let package: ID
    let productName: String
}

struct ProjectAttributes {
    let lastSwiftUpdateCheck: Int
    let lastUpgradeCheck: Int
    let targetAttributes: [ID: TargetAttributes] // Key is target id.
}

struct TargetAttributes {
    let createdOnToolsVersion: Float64
    let testTargetID: ID?
}

// An intermediate object that is used to specify that a given file is to be built for a given target.
// There are multiple of these records per file (multiplied by targets).
struct BuildFile {
    let id: ID
    let fileRef: String?
    let productRef: String?
}

// A folder on disk.
struct Group {
    let id: ID
    let children: [GroupChild]
    let path: String?
    let name: String?
    var sourceTree: String = "<group>"
}

enum GroupChild {
    case group(Group)
    case file(File)
}

enum ExplicitFileType: String {
    case app = "wrapper.application"
    case bundle = "wrapper.cfbundle"
}

enum LastKnownFileType: String {
    case swift = "sourcecode.swift"
    case storyboard = "file.storyboard"
    case assets = "folder.assetcatalog"
    case plist = "text.plist.xml"
    case file = "file" // (eg mp4)
}

// This is the canonical object for a given file's existence: 1 of these records per file.
struct File {
    let id: ID
    let lastKnownFileType: LastKnownFileType?
    let explicitFileType: ExplicitFileType?
    let includeInIndex: Bool?
    let path: String
    let sourceTree: String
}

struct Target {
    let id: ID
    let buildConfigurationList: ConfigurationList
    let buildPhases: [BuildPhaseInterface]
    let buildRules: [Void] = []
    let dependencies: [TargetDependency]
    let name: String
    let productName: String
    let productReference: ID // File.
    let productType: ProductType
    let packageProducts: [PackageProduct]
}

enum ProductType: String {
    case application = "com.apple.product-type.application"
    case unitTests = "com.apple.product-type.bundle.unit-test"
    case uiTests = "com.apple.product-type.bundle.ui-testing"
}

enum BuildPhaseCategory: String {
    case sources = "PBXSourcesBuildPhase"
    case frameworks = "PBXFrameworksBuildPhase"
    case resources = "PBXResourcesBuildPhase"
    case shellScript = "PBXShellScriptBuildPhase"
}

protocol BuildPhaseInterface {
    var id: ID { get }
    var files: [BuildFile] { get }
    func asDict() -> [String: Any]
}

struct BuildPhase {
    let id: ID
    let category: BuildPhaseCategory
    let buildActionMask: UInt64 = 2147483647
    var files: [BuildFile]
    let runOnlyForDeploymentPostprocessing: Bool = false
}

struct ShellScriptBuildPhase {
    let id: ID
    let name: String
    let category: BuildPhaseCategory = .shellScript
    let buildActionMask: UInt64 = 2147483647
    let files: [BuildFile] = []
    let inputPaths: [Void] = []
    let inputFileListPaths: [Void] = []
    let outputPaths: [Void] = []
    let outputFileListPaths: [Void] = []
    let runOnlyForDeploymentPostprocessing: Bool = false
    let shellPath: String = "/bin/sh"
    let shellScript: [String] // Each element is a line, it auto-joins them with \n.
}

struct TargetDependency {
    let id: ID
    let target: String
    let targetProxy: ContainerItemProxy
}

// An intermediate object used to assign targets as dependencies.
struct ContainerItemProxy {
    let id: ID
    let containerPortal: String // Project.
    let proxyType: Int = 1
    let remoteGlobalIDString: String // Target.
    let remoteInfo: String
}

// The configurations for a project or target.
struct ConfigurationList {
    let id: ID
    let buildConfigurations: [BuildConfiguration] // Will likely be 2: Debug and Release.
    let defaultConfigurationIsVisible: Bool = false
    let defaultConfigurationName: String
}

// A debug or release config for a project or target.
struct BuildConfiguration {
    let id: ID
    let buildSettings: [String: Any] // Values can be string, bool, array of strings, int, float64.
    let name: String
}

/**************** Model serialisation ****************/

extension Package {
    func asDict() -> [String: Any] {
        [
            "isa": "XCRemoteSwiftPackageReference",
            "repositoryURL": repositoryURL,
            "requirement": requirement,
        ]
    }
}

extension PackageProduct {
    func asDict() -> [String: Any] {
        [
            "isa": "XCSwiftPackageProductDependency",
            "package": package,
            "productName": productName,
        ]
    }
}

extension TargetAttributes {
    func asDict() -> [String: Any] {
        var dict: [String: Any] = [
            "CreatedOnToolsVersion": createdOnToolsVersion,
        ]
        if let testTargetID = testTargetID {
            dict["TestTargetID"] = testTargetID
        }
        return dict
    }
}

extension ProjectAttributes {
    func asDict() -> [String: Any] {
        return [
            "LastSwiftUpdateCheck": lastSwiftUpdateCheck,
            "LastUpgradeCheck": lastUpgradeCheck,
            "TargetAttributes": targetAttributes.mapValues({ $0.asDict() }),
        ]
    }
}

extension Project {
    func asDict() -> [String: Any] {
        return [
            "isa": "PBXProject",
            "attributes": attributes.asDict(),
            "buildConfigurationList": buildConfigurationList.id,
            "compatibilityVersion": compatibilityVersion,
            "developmentRegion": developmentRegion,
            "hasScannedForEncodings": hasScannedForEncodings,
            "knownRegions": knownRegions,
            "mainGroup": mainGroup.id,
            "packageReferences": packages.map({ $0.id }),
            "productRefGroup": productRefGroup,
            "projectDirPath": projectDirPath,
            "projectRoot": projectRoot,
            "targets": targets.map({ $0.id }),
        ]
    }
}

extension ConfigurationList {
    func asDict() -> [String: Any] {
        return [
            "isa": "XCConfigurationList",
            "buildConfigurations": buildConfigurations.map({ $0.id }),
            "defaultConfigurationIsVisible": defaultConfigurationIsVisible,
            "defaultConfigurationName": defaultConfigurationName,
        ]
    }
}

extension BuildConfiguration {
    func asDict() -> [String: Any] {
        // Build settings are the exception to the bool 0/1 rule: They are YES/NO.
        let mapped: [String: Any] = buildSettings.mapValues({ value in
            if let value = value as? Bool {
                return value ? "YES" : "NO"
            } else {
                return value
            }
        })
        return [
            "isa": "XCBuildConfiguration",
            "buildSettings": mapped,
            "name": name,
        ]
    }
}

extension Group {
    func asDict() -> [String: Any] {
        var dict: [String: Any] = [
            "isa": "PBXGroup",
            "children": children.map({ $0.id }),
            "sourceTree": sourceTree,
        ]
        if let path = path {
            dict["path"] = path
        }
        if let name = name {
            dict["name"] = name
        }
        return dict
    }
}

extension File {
    func asDict() -> [String: Any] {
        var dict: [String: Any] = [
            "isa": "PBXFileReference",
            "path": path,
            "sourceTree": sourceTree,
        ]
        if let lastKnownFileType = lastKnownFileType {
            dict["lastKnownFileType"] = lastKnownFileType.rawValue
        }
        if let explicitFileType = explicitFileType {
            dict["explicitFileType"] = explicitFileType.rawValue
        }
        if let includeInIndex = includeInIndex {
            dict["includeInIndex"] = includeInIndex
        }
        return dict
    }
}

extension Target {
    func asDict() -> [String: Any] {
        return [
            "isa": "PBXNativeTarget",
            "buildConfigurationList": buildConfigurationList.id,
            "buildPhases": buildPhases.map({ $0.id }),
            "buildRules": buildRules,
            "dependencies": dependencies.map({ $0.id }),
            "name": name,
            "productName": productName,
            "productReference": productReference,
            "productType": productType.rawValue,
            "packageProductDependencies": packageProducts.map({ $0.id }),
        ]
    }
}

extension BuildPhase: BuildPhaseInterface {
    func asDict() -> [String: Any] {
        return [
            "isa": category.rawValue,
            "buildActionMask": buildActionMask,
            "files": files.map({ $0.id }),
            "runOnlyForDeploymentPostprocessing": runOnlyForDeploymentPostprocessing,
        ]
    }
}

extension ShellScriptBuildPhase: BuildPhaseInterface {
    func asDict() -> [String: Any] {
        return [
            "isa": category.rawValue,
            "name": name,
            "buildActionMask": buildActionMask,
            "files": files.map({ $0.id }),
            "inputPaths": inputPaths,
            "inputFileListPaths": inputFileListPaths,
            "outputPaths": outputPaths,
            "outputFileListPaths": outputFileListPaths,
            "runOnlyForDeploymentPostprocessing": runOnlyForDeploymentPostprocessing,
            "shellPath": shellPath,
            "shellScript": shellScript.joined(separator: "\n"),
        ]
    }
}

extension BuildFile {
    func asDict() -> [String: Any] {
        var dict: [String: Any] = [
            "isa": "PBXBuildFile",
        ]
        if let fileRef = fileRef {
            dict["fileRef"] = fileRef
        }
        if let productRef = productRef {
            dict["productRef"] = productRef
        }
        return dict
    }
}

extension TargetDependency {
    func asDict() -> [String: Any] {
        return [
            "isa": "PBXTargetDependency",
            "target": target,
            "targetProxy": targetProxy.id,
        ]
    }
}

extension ContainerItemProxy {
    func asDict() -> [String: Any] {
        return [
            "isa": "PBXContainerItemProxy",
            "containerPortal": containerPortal,
            "proxyType": 1,
            "remoteGlobalIDString": remoteGlobalIDString,
            "remoteInfo": remoteInfo,
        ]
    }
}

/**************** Output serialisation ****************/

let alphanumericsInverted = CharacterSet.alphanumerics.inverted

func serialise(value: Any, tabs: Int) -> String {
    if let value = value as? String {
        let needsQuotes = value.isEmpty || value.rangeOfCharacter(from: alphanumericsInverted) != nil
        if needsQuotes {
            return "\"" + value.replacingOccurrences(of: "\"", with: "\\\"").replacingOccurrences(of: "\n", with: "\\n") + "\""
        } else {
            return value
        }
    } else if let value = value as? Bool {
        return value ? "1" : "0"
    } else if let value = value as? Int {
        return String(value)
    } else if let value = value as? UInt64 {
        return String(value)
    } else if let value = value as? Float64 {
        return String(value)
    } else if let value = value as? [Any] {
        return "(" + value.map({ serialise(value: $0, tabs: 0) }).joined(separator: ", ") + ")"
    } else if let value = value as? [String: Any] {
        return serialise(dict: value, tabs: tabs + 1).joined(separator: "\n")
    } else {
        fatalError("Unserialisable: \(type(of: value))")
    }
}

// Tabs is the tab level of the final outer brace, the content will be one level further in.
// The first brace is not indented.
func serialise(dict: [String: Any], tabs: Int) -> [String] {
    let braceTabs = String(repeating: "\t", count: tabs)
    let contentTabs = String(repeating: "\t", count: tabs + 1)
    var lines: [String] = []
    for (key, value) in dict {
        lines.append(contentTabs + key + " = " + serialise(value: value, tabs: tabs) + ";")
    }
    lines.sort() // So its predictably ordered.
    lines.insert("{", at: 0)
    lines.append(braceTabs + "}")
    return lines
}

// Returns [group.id: group.AsDict, ... all children groups/files too].
func serialiseRecursively(group: Group) -> [String: Any] {
    var objects: [String: Any] = [:]
    objects[group.id] = group.asDict()
    for child in group.children {
        switch child {
        case .group(let group):
            let other = serialiseRecursively(group: group)
            objects.merge(other, uniquingKeysWith: { $1 })
        case .file(let file):
            objects[file.id] = file.asDict()
        }
    }
    return objects
}

func serialise(project: Project) -> String {
    var objects: [String: Any] = [:]
    objects[project.id] = project.asDict()
    objects[project.buildConfigurationList.id] = project.buildConfigurationList.asDict()
    for config in project.buildConfigurationList.buildConfigurations {
        objects[config.id] = config.asDict()
    }
    objects.merge(serialiseRecursively(group: project.mainGroup), uniquingKeysWith: { $1 })
    for target in project.targets {
        objects[target.id] = target.asDict()
        objects[target.buildConfigurationList.id] = target.buildConfigurationList.asDict()
        for config in target.buildConfigurationList.buildConfigurations {
            objects[config.id] = config.asDict()
        }
        for phase in target.buildPhases {
            objects[phase.id] = phase.asDict()
            for file in phase.files {
                objects[file.id] = file.asDict()
            }
        }
        for dep in target.dependencies {
            objects[dep.id] = dep.asDict()
            objects[dep.targetProxy.id] = dep.targetProxy.asDict()
        }
        for product in target.packageProducts {
            objects[product.id] = product.asDict()
        }
    }
    for package in project.packages {
        objects[package.id] = package.asDict()
    }
    let classes: [String: Any] = [:]
    let root: [String: Any] = [
        "archiveVersion": 1,
        "classes": classes,
        "objectVersion": 52,
        "objects": objects,
        "rootObject": project.id,
    ]
    var lines: [String] = []
    lines.append("// !$*UTF8*$!")
    lines.append(contentsOf: serialise(dict: root, tabs: 0))
    return lines.joined(separator: "\n")
}

/**************** Helpers ****************/

extension LastKnownFileType {
    static func from(pathExtension: String) -> LastKnownFileType {
        switch pathExtension.lowercased() {
        case "swift": return .swift
        case "storyboard": return .storyboard
        case "xcassets": return .assets
        case "plist": return .plist
        default: return .file
        }
    }
}

extension GroupChild {
    var id: String {
        switch self {
        case .group(let value): return value.id
        case .file(let value): return value.id
        }
    }
}

extension Group {
    /// All files in this group, and this group's child group's files, etc.
    /// Ignores any groups in the ignoreGroupsNamed set.
    func allFilesRecursively(ignoreGroupsNamed: Set<String>) -> [File] {
        var files: [File] = []
        for child in children {
            switch child {
            case .group(let g):
                guard !ignoreGroupsNamed.contains(g.path ?? "") else { continue }
                files.append(contentsOf: g.allFilesRecursively(ignoreGroupsNamed: ignoreGroupsNamed))
            case .file(let f):
                files.append(f)
            }
        }
        return files
    }
}

extension WhiteLabelModels {
    var allProductFiles: [File] {
        return [app, unitTests, uiTests]
    }
}

extension Collection where Element == WhiteLabelModels {
    var allTargets: [Target] {
        flatMap { $0.targets }
    }

    var allTargetAttributes: [ID: TargetAttributes] {
        var att: [ID: TargetAttributes] = [:]
        for ta in self {
            att.merge(ta.targetAttributes, uniquingKeysWith: { $1 })
        }
        return att
    }

    var allProductFiles: [File] {
        flatMap { $0.allProductFiles }
    }
}

/**************** File searching ****************/

enum FilePurpose {
    case source
    case resource
    case unitTest
    case uiTest
}

// Figure out which file goes in which target/phase.
func determineFilePurposes(source: Group, unitTests: Group, uiTests: Group, ignoreGroupsNamed: Set<String>) -> [String: FilePurpose] {
    // Combine all files.
    enum Location {
        case source
        case unit
        case ui
    }
    struct FileLocation {
        let file: File
        let location: Location
    }
    var files: [FileLocation] = []
    files.append(contentsOf: source.allFilesRecursively(ignoreGroupsNamed: ignoreGroupsNamed).map({ FileLocation(file: $0, location: .source) }))
    files.append(contentsOf: unitTests.allFilesRecursively(ignoreGroupsNamed: ignoreGroupsNamed).map({ FileLocation(file: $0, location: .unit) }))
    files.append(contentsOf: uiTests.allFilesRecursively(ignoreGroupsNamed: ignoreGroupsNamed).map({ FileLocation(file: $0, location: .ui) }))

    var purposes: [String: FilePurpose] = [:]
    for file in files {
        let lowerPath = file.file.path.lowercased()
        guard lowerPath != "info.plist" else { continue } // Plists don't belong as a resource.
        guard lowerPath != "schema.json" else { continue } // Don't include Apollo's graphql schema.
        guard !lowerPath.hasSuffix(".graphql") else { continue }
        let pathContainsTest = lowerPath.contains("test")
        let pathHasExtensionSwift = lowerPath.hasSuffix(".swift")
        let purpose: FilePurpose?
        switch (file.location, pathContainsTest, pathHasExtensionSwift) {
        case (.source, false, true):
            purpose = .source
        case (.source, false, false):
            purpose = .resource
        case (.source, true, true):
            purpose = .unitTest
        case (.source, true, false):
            fatalError("Unexpected non-source test file: \(file.file)")
        case (.unit, _, _):
            purpose = .unitTest
        case (.ui, _, _):
            purpose = .uiTest
        }
        purposes[file.file.id] = purpose
    }
    return purposes
}

extension URL: Comparable {
    public static func < (lhs: URL, rhs: URL) -> Bool {
        return lhs.absoluteString.lowercased() < rhs.absoluteString.lowercased()
    }
}

/// Figure out which files would be a group's children under a given folder.
func groupChildren(url: URL) -> [GroupChild] {
    let contents = try! FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey], options: [])
    var children: [GroupChild] = []
    for content in contents.sorted() {
        guard content.lastPathComponent != ".DS_Store" else { continue }
        let values = try! content.resourceValues(forKeys: [.isDirectoryKey])
        let isDirectory = values.isDirectory ?? false
        let type = LastKnownFileType.from(pathExtension: content.pathExtension)
        if isDirectory && type != .assets { // Assets are a special type of directory that are considered a 'file' like a mac bundle.
            let grandChildren = groupChildren(url: content)
            let group = Group(id: .next(.group),
                            children: grandChildren,
                            path: content.lastPathComponent,
                            name: nil,
                            sourceTree: "<group>")
            children.append(.group(group))
        } else {
            let file = File(id: .next(.file),
                            lastKnownFileType: .from(pathExtension: content.pathExtension),
                            explicitFileType: nil,
                            includeInIndex: nil,
                            path: content.lastPathComponent,
                            sourceTree: "<group>")
            children.append(.file(file))
        }
    }
    return children
}

/**************** Build settings ****************/

// The most-common build settings, shared by debug/release/etc at the project level and inherited at target level.
// This is the first place you should attempt to put any new build settting.
let projectCommonBuildSettings: [String : Any] = [
    "ALWAYS_SEARCH_USER_PATHS": false,
    "CLANG_ANALYZER_NONNULL": true,
    "CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION": "YES_AGGRESSIVE",
    "CLANG_CXX_LANGUAGE_STANDARD": "gnu++14",
    "CLANG_CXX_LIBRARY": "libc++",
    "CLANG_ENABLE_MODULES": true,
    "CLANG_ENABLE_OBJC_ARC": true,
    "CLANG_ENABLE_OBJC_WEAK": true,
    "CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING": true,
    "CLANG_WARN_BOOL_CONVERSION": true,
    "CLANG_WARN_COMMA": true,
    "CLANG_WARN_CONSTANT_CONVERSION": true,
    "CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS": true,
    "CLANG_WARN_DIRECT_OBJC_ISA_USAGE": "YES_ERROR",
    "CLANG_WARN_DOCUMENTATION_COMMENTS": true,
    "CLANG_WARN_EMPTY_BODY": true,
    "CLANG_WARN_ENUM_CONVERSION": true,
    "CLANG_WARN_INFINITE_RECURSION": true,
    "CLANG_WARN_INT_CONVERSION": true,
    "CLANG_WARN_NON_LITERAL_NULL_CONVERSION": true,
    "CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF": true,
    "CLANG_WARN_OBJC_LITERAL_CONVERSION": true,
    "CLANG_WARN_OBJC_ROOT_CLASS": "YES_ERROR",
    "CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER": true,
    "CLANG_WARN_RANGE_LOOP_ANALYSIS": true,
    "CLANG_WARN_STRICT_PROTOTYPES": true,
    "CLANG_WARN_SUSPICIOUS_MOVE": true,
    "CLANG_WARN_UNGUARDED_AVAILABILITY": "YES_AGGRESSIVE",
    "CLANG_WARN_UNREACHABLE_CODE": true,
    "CLANG_WARN__DUPLICATE_METHOD_MATCH": true,
    "COPY_PHASE_STRIP": false,
    "ENABLE_STRICT_OBJC_MSGSEND": true,
    "GCC_C_LANGUAGE_STANDARD": "gnu11",
    "GCC_NO_COMMON_BLOCKS": true,
    "GCC_WARN_64_TO_32_BIT_CONVERSION": true,
    "GCC_WARN_ABOUT_RETURN_TYPE": "YES_ERROR",
    "GCC_WARN_UNDECLARED_SELECTOR": true,
    "GCC_WARN_UNINITIALIZED_AUTOS": "YES_AGGRESSIVE",
    "GCC_WARN_UNUSED_FUNCTION": true,
    "GCC_WARN_UNUSED_VARIABLE": true,
    "IPHONEOS_DEPLOYMENT_TARGET": 14.4,
    "MTL_FAST_MATH": true,
    "SDKROOT": "iphoneos",
    "DEVELOPMENT_TEAM": "ABCDEF1234", // TODO change this to yours!
    "PRODUCT_NAME": "$(TARGET_NAME)",
    "SWIFT_VERSION": "5.0",
    "TARGETED_DEVICE_FAMILY": "1",
]

// The project-level build settings that are different for debug vs common.
let projectDebugExtraBuildSettings: [String : Any] = [
    "DEBUG_INFORMATION_FORMAT": "dwarf",
    "ENABLE_TESTABILITY": true,
    "GCC_DYNAMIC_NO_PIC": false,
    "GCC_OPTIMIZATION_LEVEL": 0,
    "GCC_PREPROCESSOR_DEFINITIONS": ["DEBUG=1", "$(inherited)"],
    "MTL_ENABLE_DEBUG_INFO": "INCLUDE_SOURCE",
    "ONLY_ACTIVE_ARCH": true,
    "SWIFT_ACTIVE_COMPILATION_CONDITIONS": "DEBUG",
    "SWIFT_OPTIMIZATION_LEVEL": "-Onone",
]

// The project-level build settings that are different for release vs common.
let projectReleaseExtraBuildSettings: [String : Any] = [
    "DEBUG_INFORMATION_FORMAT": "dwarf-with-dsym",
    "ENABLE_NS_ASSERTIONS": false,
    "MTL_ENABLE_DEBUG_INFO": false,
    "SWIFT_COMPILATION_MODE": "wholemodule",
    "SWIFT_OPTIMIZATION_LEVEL": "-O",
    "VALIDATE_PRODUCT": true,
]

let projectDebugBuildSettings = projectCommonBuildSettings.merging(projectDebugExtraBuildSettings,
                                                                uniquingKeysWith: { $1 })
let projectReleaseBuildSettings = projectCommonBuildSettings.merging(projectReleaseExtraBuildSettings,
                                                                    uniquingKeysWith: { $1 })

// Target build settings.
func mainTargetCommonBuildSettings(for whiteLabel: WhiteLabel) -> [String : Any] {
    return [
        "ASSETCATALOG_COMPILER_APPICON_NAME": "AppIcon",
        "ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME": "Primary",
        "INFOPLIST_FILE": whiteLabel.plist,
        "LD_RUNPATH_SEARCH_PATHS": ["$(inherited)", "@executable_path/Frameworks"],
        "PRODUCT_BUNDLE_IDENTIFIER": whiteLabel.bundleId,
    ]
}
func mainTargetDebugBuildSettings(for whiteLabel: WhiteLabel) -> [String : Any] {
    let common = mainTargetCommonBuildSettings(for: whiteLabel)
    let extras: [String : Any] = [:] // Allow for future debug-only settings.
    return common.merging(extras, uniquingKeysWith: { $1 })
}
func mainTargetReleaseBuildSettings(for whiteLabel: WhiteLabel) -> [String : Any] {
    let common = mainTargetCommonBuildSettings(for: whiteLabel)
    let extras: [String : Any] = [:] // Allow for future release-only settings.
    return common.merging(extras, uniquingKeysWith: { $1 })
}

// Test build settings.
func unitTestTargetBuildSettings(for whiteLabel: WhiteLabel) -> [String : Any] {
    [
        "ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES": true,
        "LD_RUNPATH_SEARCH_PATHS": ["$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks"],
        "INFOPLIST_FILE": "UnitTests/Info.plist",
        "PRODUCT_BUNDLE_IDENTIFIER": whiteLabel.bundleId + ".UnitTests",
        "BUNDLE_LOADER": "$(TEST_HOST)",
        "TEST_HOST": "$(BUILT_PRODUCTS_DIR)/\(whiteLabel.name).app/\(whiteLabel.name)",
    ]
}

func uiTestTargetBuildSettings(for whiteLabel: WhiteLabel) -> [String : Any] {
    [
        "ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES": true,
        "LD_RUNPATH_SEARCH_PATHS": ["$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks"],
        "INFOPLIST_FILE": "UITests/Info.plist",
        "PRODUCT_BUNDLE_IDENTIFIER": whiteLabel.bundleId + ".UITests",
        "TEST_TARGET_NAME": whiteLabel.name,
    ]
}

/**************** Project assembly ****************/

// Reserve some IDs so they come out in a neat order for smaller diffs, and so they can be referenced before their objects are created.
let projectID = ID.next(.project)
let mainGroupID = ID.next(.group)
let sourceGroupID = ID.next(.group)
let unitTestsGroupID = ID.next(.group)
let uiTestsGroupID = ID.next(.group)
let productsGroupID = ID.next(.group)
let frameworksGroupID = ID.next(.group)

let projectRootURL = URL(string: projectRootFolder)!
let sourceURL = URL(string: sourceFolder, relativeTo: projectRootURL)!
let unitTestsURL = URL(string: unitTestsFolder, relativeTo: projectRootURL)!
let uiTestsURL = URL(string: uiTestsFolder, relativeTo: projectRootURL)!

let sourceGroup = Group(id: sourceGroupID, children: groupChildren(url: sourceURL), path: sourceFolder, name: nil)
let unitTestsGroup = Group(id: unitTestsGroupID, children: groupChildren(url: unitTestsURL), path: unitTestsFolder, name: nil)
let uiTestsGroup = Group(id: uiTestsGroupID, children: groupChildren(url: uiTestsURL), path: uiTestsFolder, name: nil)

struct WhiteLabelModels {
    let targets: [Target]
    let targetAttributes: [ID: TargetAttributes]
    let app: File
    let unitTests: File
    let uiTests: File
}

// Create the targets/etc for a given whitelabel.
func models(for whiteLabel: WhiteLabel) -> WhiteLabelModels {

    let mainTargetDebugBuildConfig = BuildConfiguration(id: .next(.buildConfiguration),
                                                        buildSettings: mainTargetDebugBuildSettings(for: whiteLabel),
                                                        name: "Debug")
    let mainTargetReleaseBuildConfig = BuildConfiguration(id: .next(.buildConfiguration),
                                                        buildSettings: mainTargetReleaseBuildSettings(for: whiteLabel),
                                                        name: "Release")
    let unitTestTargetDebugBuildConfig = BuildConfiguration(id: .next(.buildConfiguration),
                                                            buildSettings: unitTestTargetBuildSettings(for: whiteLabel),
                                                            name: "Debug")
    let unitTestTargetReleaseBuildConfig = BuildConfiguration(id: .next(.buildConfiguration),
                                                            buildSettings: unitTestTargetBuildSettings(for: whiteLabel),
                                                            name: "Release")
    let uiTestTargetDebugBuildConfig = BuildConfiguration(id: .next(.buildConfiguration),
                                                        buildSettings: uiTestTargetBuildSettings(for: whiteLabel),
                                                        name: "Debug")
    let uiTestTargetReleaseBuildConfig = BuildConfiguration(id: .next(.buildConfiguration),
                                                            buildSettings: uiTestTargetBuildSettings(for: whiteLabel),
                                                            name: "Release")

    let mainTargetConfigList = ConfigurationList(id: .next(.configurationList),
                                                buildConfigurations: [mainTargetDebugBuildConfig, mainTargetReleaseBuildConfig],
                                                defaultConfigurationName: "Release")
    let unitTestTargetConfigList = ConfigurationList(id: .next(.configurationList),
                                                    buildConfigurations: [unitTestTargetDebugBuildConfig, unitTestTargetReleaseBuildConfig],
                                                    defaultConfigurationName: "Release")
    let uiTestTargetConfigList = ConfigurationList(id: .next(.configurationList),
                                                buildConfigurations: [uiTestTargetDebugBuildConfig, uiTestTargetReleaseBuildConfig],
                                                defaultConfigurationName: "Release")

    let appFile = File(id: .next(.file),
                    lastKnownFileType: nil,
                    explicitFileType: .app,
                    includeInIndex: false,
                    path: whiteLabel.name + ".app",
                    sourceTree: "BUILT_PRODUCTS_DIR")
    let unitTestsFile = File(id: .next(.file),
                            lastKnownFileType: nil,
                            explicitFileType: .bundle,
                            includeInIndex: false,
                            path: whiteLabel.name + "UnitTests.xctest",
                            sourceTree: "BUILT_PRODUCTS_DIR")
    let uiTestsFile = File(id: .next(.file),
                        lastKnownFileType: nil,
                        explicitFileType: .bundle,
                        includeInIndex: false,
                        path: whiteLabel.name + "UITests.xctest",
                        sourceTree: "BUILT_PRODUCTS_DIR")

    let apolloPhase = ShellScriptBuildPhase(id: .next(.buildPhase),
        name: "Apollo",
        shellScript: [
            "exit 0 # Normally we want to skip the slow apollo codegen; comment this to run it.",
            "",
            "# Go to the build root and search up the chain to find the Derived Data Path where the source packages are checked out.",
            "DERIVED_DATA_CANDIDATE=\"${BUILD_ROOT}\"",
            "",
            "while ! [ -d \"${DERIVED_DATA_CANDIDATE}/SourcePackages\" ]; do",
            "  if [ \"${DERIVED_DATA_CANDIDATE}\" = / ]; then",
            "    echo >&2 \"error: Unable to locate SourcePackages directory from BUILD_ROOT: '${BUILD_ROOT}'\"",
            "    exit 1",
            "  fi",
            "",
            "  DERIVED_DATA_CANDIDATE=\"$(dirname \"${DERIVED_DATA_CANDIDATE}\")\"",
            "done",
            "",
            "# Grab a reference to the directory where scripts are checked out",
            "SCRIPT_PATH=\"${DERIVED_DATA_CANDIDATE}/SourcePackages/checkouts/apollo-ios/scripts\"",
            "",
            "if [ -z \"${SCRIPT_PATH}\" ]; then",
            "    echo >&2 \"error: Couldn't find the CLI script in your checked out SPM packages; make sure to add the framework to your project.\"",
            "    exit 1",
            "fi",
            "",
            "cd \"${SRCROOT}/Source/GraphQL\"",
            "\"${SCRIPT_PATH}\"/run-bundled-codegen.sh codegen:generate --target=swift --includes=./**/*.graphql --localSchemaFile=\"schema.json\" API.swift",
        ])
    let buildNumberPhase = ShellScriptBuildPhase(id: .next(.buildPhase),
        name: "Build number",
        shellScript: [
            "if [[ $CONFIGURATION == Release ]]; then",
            "  BUNDLE_VERSION=`date \"+%Y%m%d%H%M%S\"`",
            "  /usr/libexec/PlistBuddy -c \"Set CFBundleVersion $BUNDLE_VERSION\" $INFOPLIST_FILE",
            "  echo \"Bumping bundle version to $BUNDLE_VERSION because this is a release build.\"",
            "else",
            "  echo \"Not bumping bundle version, this isn't a release build.\"",
            "fi",
        ])

    var mainSources = BuildPhase(id: .next(.buildPhase), category: .sources, files: [])
    var mainFrameworks = BuildPhase(id: .next(.buildPhase), category: .frameworks, files: [])
    var mainResources = BuildPhase(id: .next(.buildPhase), category: .resources, files: [])
    var unitSources = BuildPhase(id: .next(.buildPhase), category: .sources, files: [])
    let unitFrameworks = BuildPhase(id: .next(.buildPhase), category: .frameworks, files: [])
    let unitResources = BuildPhase(id: .next(.buildPhase), category: .resources, files: [])
    var uiSources = BuildPhase(id: .next(.buildPhase), category: .sources, files: [])
    let uiFrameworks = BuildPhase(id: .next(.buildPhase), category: .frameworks, files: [])
    let uiResources = BuildPhase(id: .next(.buildPhase), category: .resources, files: [])

    // Get the other white labels so we can ignore their source folders.
    let otherWhiteLabels = whiteLabels.map({ $0.name }).filter({ $0 != whiteLabel.name })

    // Figure out the purpose of all files in all groups.
    let purposes = determineFilePurposes(source: sourceGroup, unitTests: unitTestsGroup, uiTests: uiTestsGroup, ignoreGroupsNamed: Set(otherWhiteLabels))
    for (file, purpose) in purposes {
        switch purpose {
        case .source:
            mainSources.files.append(BuildFile(id: .next(.buildFile), fileRef: file, productRef: nil))
        case .resource:
            mainResources.files.append(BuildFile(id: .next(.buildFile), fileRef: file, productRef: nil))
        case .unitTest:
            unitSources.files.append(BuildFile(id: .next(.buildFile), fileRef: file, productRef: nil))
        case .uiTest:
            uiSources.files.append(BuildFile(id: .next(.buildFile), fileRef: file, productRef: nil))
        }
    }

    // Get all the products in all the packages.
    let packageProducts: [PackageProduct] = configPackages.flatMap { package in
        package.products.map { product in
            PackageProduct(id: .next(.packageProduct),
                        package: package.id,
                        productName: product)
        }
    }
    for product in packageProducts {
        mainFrameworks.files.append(BuildFile(id: .next(.buildFile), fileRef: nil, productRef: product.id))
    }

    let mainTarget = Target(id: .next(.target),
                            buildConfigurationList: mainTargetConfigList,
                            buildPhases: [apolloPhase, buildNumberPhase, mainSources, mainFrameworks, mainResources],
                            dependencies: [],
                            name: whiteLabel.name,
                            productName: whiteLabel.name,
                            productReference: appFile.id,
                            productType: .application,
                            packageProducts: packageProducts)

    let unitTargetDepProxy = ContainerItemProxy(id: .next(.containerItemProxy),
                                                containerPortal: projectID,
                                                remoteGlobalIDString: mainTarget.id,
                                                remoteInfo: mainTarget.name)
    let uiTargetDepProxy = ContainerItemProxy(id: .next(.containerItemProxy),
                                            containerPortal: projectID,
                                            remoteGlobalIDString: mainTarget.id,
                                            remoteInfo: mainTarget.name)
    let unitTargetDep = TargetDependency(id: .next(.targetDependency),
                                        target: mainTarget.id,
                                        targetProxy: unitTargetDepProxy)
    let uiTargetDep = TargetDependency(id: .next(.targetDependency),
                                    target: mainTarget.id,
                                    targetProxy: uiTargetDepProxy)

    let unitTestTarget = Target(id: .next(.target),
                                buildConfigurationList: unitTestTargetConfigList,
                                buildPhases: [unitSources, unitFrameworks, unitResources],
                                dependencies: [unitTargetDep],
                                name: whiteLabel.name + "UnitTests",
                                productName: whiteLabel.name + "UnitTests",
                                productReference: unitTestsFile.id,
                                productType: .unitTests,
                                packageProducts: [])
    let uiTestTarget = Target(id: .next(.target),
                            buildConfigurationList: uiTestTargetConfigList,
                            buildPhases: [uiSources, uiFrameworks, uiResources],
                            dependencies: [uiTargetDep],
                            name: whiteLabel.name + "UITests",
                            productName: whiteLabel.name + "UITests",
                            productReference: uiTestsFile.id,
                            productType: .uiTests,
                            packageProducts: [])

    return WhiteLabelModels(targets: [mainTarget, unitTestTarget, uiTestTarget],
                                targetAttributes: [
                                    mainTarget.id:     TargetAttributes(createdOnToolsVersion: 12.4, testTargetID: nil),
                                    unitTestTarget.id: TargetAttributes(createdOnToolsVersion: 12.4, testTargetID: mainTarget.id),
                                    uiTestTarget.id:   TargetAttributes(createdOnToolsVersion: 12.4, testTargetID: mainTarget.id),
                                ],
                                app: appFile,
                                unitTests: unitTestsFile,
                                uiTests: uiTestsFile)
}

// Combine the whitelabelstargets/etc for a given whitelabel.
let allWhiteLabelModels: [WhiteLabelModels] = whiteLabels.map(models(for:))

// There is one main and one products group per project, not per target, so they're made here, outside of models(for whiteLabel:).
let productChildren: [GroupChild] = allWhiteLabelModels.allProductFiles.map { .file($0) }
let productsGroup = Group(id: productsGroupID,
                        children: productChildren,
                        path: nil,
                        name: "Products")
let frameworksGroup = Group(id: frameworksGroupID,
                            children: [],
                            path: nil,
                            name: "Frameworks")
                        
let mainGroup = Group(id: mainGroupID,
                    children: [
                        .group(sourceGroup),
                        .group(unitTestsGroup),
                        .group(uiTestsGroup),
                        .group(productsGroup),
                        .group(frameworksGroup)],
                    path: nil,
                    name: nil)

// The single project!
let projectAttributes = ProjectAttributes(lastSwiftUpdateCheck: 1240,
                                        lastUpgradeCheck: 1240,
                                        targetAttributes: allWhiteLabelModels.allTargetAttributes)
let projectDebugBuildConfig = BuildConfiguration(id: .next(.buildConfiguration),
                                                buildSettings: projectDebugBuildSettings,
                                                name: "Debug")
let projectReleaseBuildConfig = BuildConfiguration(id: .next(.buildConfiguration),
                                                buildSettings: projectReleaseBuildSettings,
                                                name: "Release")
let projectConfigList = ConfigurationList(id: .next(.configurationList),
                                        buildConfigurations: [projectDebugBuildConfig, projectReleaseBuildConfig],
                                        defaultConfigurationName: "Release")
let packages: [Package] = configPackages.map {
    Package(id: $0.id,
            repositoryURL: $0.url,
            requirement: $0.requirement)
}
let project = Project(id: projectID,
                    attributes: projectAttributes,
                    buildConfigurationList: projectConfigList,
                    compatibilityVersion: "Xcode 9.3",
                    developmentRegion: "en",
                    hasScannedForEncodings: false,
                    knownRegions: ["en", "Base"],
                    mainGroup: mainGroup,
                    productRefGroup: productsGroup.id,
                    projectDirPath: "",
                    projectRoot: "",
                    targets: allWhiteLabelModels.allTargets,
                    packages: packages)

/**************** Output ****************/

// Write it.
print("[ProjectGenerator] Saving")
let serialisedProject = serialise(project: project)
try! FileManager.default.createDirectory(atPath: xcodeprojPath, withIntermediateDirectories: true, attributes: nil)
try! serialisedProject.write(toFile: pbxprojPath, atomically: true, encoding: .utf8)
print("[ProjectGenerator] Done")

Thanks for reading, I hope this is helpful, God bless :)

Photo by Wolfgang Hasselmann on Unsplash


You can see older posts in the right panel, under 'archive'.

Archive

Swift Security framework wrapper for RSA and Elliptic Curve encryption / decryption 21 Sep 2021

Simple, practical async await Swift examples 3 Jul 2021

Xcode pbxproj project generator in Swift 17 May 2021

UITableViewDiffableDataSource for adding and removing rows automatically to a table view in Swift 10 May 2021

Super simple iOS Combine example 23 Feb 2021

Introducing Chalkinator: Native desktop blogging app 7 Jun 2020

Flare: Open source 2-way folder sync to Backblaze B2 in Swift 28 May 2020

Making a baby monitor out of a couple of ESP32s, an I2S microphone, and a small speaker 16 Apr 2020

Chris' 2020 guide to hosting a HTTPS static site on AWS S3 + Cloudfront 15 Mar 2020

Simple Javascript debounce, no libraries needed 20 Feb 2020

Asynchronous NSOperations in Swift 5 3 Jan 2020

Deploying Golang Revel sites to AWS Elastic Beanstalk 9 Dec 2019

Golang and pure Swift Compression and Decompression 28 Jul 2019

Pure Swift simple Keychain wrapper 23 Jun 2019

Pure Swift 5 CommonCrypto AES Encryption 9 Jun 2019

Bluetooth example code for Swift/iOS 6 Jun 2019

Talking to a Bluetooth LE peripheral with Swift/iOS 18 May 2019

Obfuscating Keys using Swift 5 May 2019

State Machines in Swift using enums 10 Apr 2019

iOS timers without circular references with Pendulum 28 Mar 2019

Pragmatic Reactive Programming 11 Oct 2017

React Native first impressions 7 Apr 2017

Gondola 26 Feb 2017

Scalable Swift 22 Nov 2016

Swift 3 Migration 6 Nov 2016

Enum-Driven View Controllers 3 Jan 2016

Status bar colours: Everything there is to know 30 Dec 2015

Android server 20 Dec 2015

Generating heightmap terrain with Swift 8 Nov 2015

Swift Education Screencasts 27 Oct 2015

Swift Image Cache 24 Sep 2015

Don't be slack 13 Sep 2015

Swift KVO alternative 23 Jul 2015

Swift Keychain wrapper 21 Jun 2015

Swift NSURLSession wrapper 12 Jun 2015

iOS8 View Controller transitioning bug 17 Apr 2015

IB Designable 18 Mar 2015

iOS App Architecture 2 Mar 2015

Video Course Launch 14 Feb 2015

Video Course Pre-launch 8 Feb 2015

Blogging Platforms 13 Jan 2015

Mobile in 2014 - Year in Review 11 Jan 2015

Secret Keys talk 16 Nov 2014

Dimmi 11 Nov 2014

Project setup in Xcode6 22 Oct 2014

Uploading to an S3 bucket from iOS 15 Oct 2014

iOS8 App Testing Roundup 28 Sep 2014

Storing obfuscated secret keys in your iOS app 16 Sep 2014

Getting Core Location / CLLocationManager to work on iOS8 14 Sep 2014

Accessing the response body in failure blocks with AFNetworking 2 10 Sep 2014

How to allow your UITextFields to scroll out of the way of the keyboard 8 Sep 2014

How to subclass UIButton in iOS7 and make a UIButtonTypeSystem 4 Sep 2014

New season 1 Aug 2014

House finished 17 Jun 2014

WebP decoding on iOS 9 Feb 2014

Moving on again 22 Jan 2014

Lossy images for retina iPads - JPEG vs WebP 30 Nov 2013

Career options I wish I knew about when I was younger 20 Oct 2013

Positivity and your friends 7 Oct 2013

Tactility 26 Jul 2013

WWDC-induced narcolepsy 15 Jul 2013

Back on rails 31 May 2013

Full circle 6 May 2013

Programmatic UI on iOS 3 May 2013

Screencasts and positivity 8 Apr 2013

Year of positivity 14 Mar 2013

iOS Dev State of the Union 6 Feb 2013

Adventures with IAPs 3 Feb 2013

No longer a Googler 23 Dec 2012

Localising iPhone apps with Microsoft Translator 8 Dec 2012

Fight back (app biz update 13) 12 Nov 2012

Sent to the backburner (app biz update 12) 25 Oct 2012

Lisi Schappi 7 Oct 2012

Today's happy plateau (app biz update 11) 26 Aug 2012

First week's sales of Today (app biz update 10) 19 Aug 2012

Today launch! And a difficult decision made... (app biz update 9) 15 Aug 2012

Approved! (app biz update 8) 5 Aug 2012

Creating a graph in Objective-C on the iPhone 3 Aug 2012

Hurry up and wait (app biz update 7) 30 Jul 2012

Today app marketing site 27 Jul 2012

Today app submitted 25 Jul 2012

UIAlertView input wrapper 24 Jul 2012

Mentoring 23 Jul 2012

This is too hard! (app biz update 6) 20 Jul 2012

Perspectives (app biz update 5) 9 Jul 2012

4th starting-my-own-biz update 1 Jul 2012

ScrumFox landing page 28 Jun 2012

Server Scope landing page 27 Jun 2012

Telstra Calls and Data Usage 26 Jun 2012

Service History + Dropbox 26 Jun 2012

Impromptu Presenter 26 Jun 2012

Fertility Tracker 26 Jun 2012

Baby Allergy Tracker 26 Jun 2012

Starting my own business, update 3 22 Jun 2012

Starting my own business, update 2 17 Jun 2012

Starting my own business - First update 10 Jun 2012

I must be crazy 6 Jun 2012

Finding your location on an iPhone 7 May 2012

A generous career 4 May 2012

Skeleton Key Cocoaheads presentation 3 May 2012

CHBgDropboxSync - Dropbox auto-sync for your iOS apps 1 May 2012

That book about that Steve Jobs guy 30 Apr 2012

Another app marketing idea 23 Apr 2012

Sweet grouped tables on the iPhone 17 Apr 2012

Skeleton Key App 11 Apr 2012

Another app marketing idea... 5 Apr 2012

Quickly check for any missing retina graphics in your project 3 Apr 2012

Skeleton Key Password Manager with Dropbox 2 Apr 2012

RC Boat motor finally mounted 2 Apr 2012

Promoting apps presentation slides 1 Apr 2012

How i just wasted a month on my latest app, and how you don't need to 26 Mar 2012

The Finishing Line 20 Mar 2012

Using Launchd to run a script every 5 mins on a Mac 20 Feb 2012

Generating AES256 keys from a password/passphrase in ObjC 20 Feb 2012

Indie iPhone app marketing, part 2 19 Feb 2012

My App Manifesto: Syncing + Dropbox + YAML = Awesome 15 Feb 2012

Indie iPhone App Marketing part 1 7 Feb 2012

Perspectives 2 Feb 2012

Accountability and Free Will 1 Feb 2012

Badassery 31 Jan 2012

Sacrifice 30 Jan 2012

Lead Yourself First 29 Jan 2012

How to ping a server in Objective-C / iPhone 26 Jan 2012

iOS Automated Builds with Xcode4 16 Jan 2012

Xcode 4 - Command line builds of iPhone apps 15 Jan 2012

Guest post by Jason McDougall 13 Jan 2012

Scouts, Games and Motivation 10 Jan 2012

2011 Re-cap 8 Jan 2012

Ruby script to increment a build number 4 Jan 2012

Turning 30? All ideas, no execution? 18 Dec 2011

CHDropboxSync - simply sync your iOS app's documents to Dropbox 14 Dec 2011

Deep-enumerating a directory on the iphone, getting file attributes as you go 10 Dec 2011

Getting a date without the time component in objective-c 6 Dec 2011

Memory management in Objective-C 4 Dec 2011

Starting small 29 Nov 2011

Dictionary Types Helper 29 Nov 2011

Observer Pattern in Objective-C 16 Nov 2011

Why you should give presentations 13 Nov 2011

How to get a programming or design job in Sydney 9 Nov 2011

Custom nav bar / toolbar backgrounds in iOS5 8 Nov 2011

Stuck 27 Oct 2011

Dead easy singletons in Obj-C 19 Oct 2011

JSON vs OCON (Objective-C Object Notation) 18 Oct 2011

In defence of Objective-C 16 Oct 2011

Update the MessagePack objective-c library to support packing 12 Oct 2011

Icons 11 Oct 2011

How to host a site on Amazon AWS S3, step-by-step 7 Oct 2011

Drawing a textured pattern over the default UINavigationBar 6 Oct 2011

Markdown Presentations 1 Oct 2011

More MegaComet testing: Ruling out keepalives 15 Sep 2011

MegaComet test #4 - This time with more kernel 14 Sep 2011

Building People 10 Sep 2011

Half way there: Getting MegaComet to 523,000 concurrent HTTP connections 5 Sep 2011

Making a progress bar in your iPhone UINavigationBar 22 Aug 2011

Hacker News Reader 20 Aug 2011

How to programmatically resize elements for landscape vs portrait in your iphone interface 16 Aug 2011

MegaComet testing part 2 3 Aug 2011

Australian Baby Colours 28 Jul 2011

Boat prop shaft 25 Jul 2011

Megacomet with 1 million queued messages 24 Jul 2011

Installed the strut and rudder 18 Jul 2011

Painted the inside of the boat 17 Jul 2011

Fuzzy iphone graphics when using an UIImageView set to UIViewContentModeCenter 13 Jul 2011

My 3 Data and Calls Usage 11 Jul 2011

Reading a line from the console in node.js 10 Jul 2011

Trim whitespaces on all text fields in a view controller 9 Jul 2011

Final finish 9 Jul 2011

MessagePack parser for Objective-C / iPhone 30 Jun 2011

Lacquering the starboard side 25 Jun 2011

What do do with EXC_ARM_DA_ALIGN on an iPhone app 23 Jun 2011

Lacquering the hull 23 Jun 2011

Staining the boat 22 Jun 2011

NSMutableSet with weak references in objective-c 20 Jun 2011

Iphone gesture recogniser that works for baby games 20 Jun 2011

Image manipulation pixel by pixel in objective C for the iphone 19 Jun 2011

Baby Allergy Tracker 12 Jun 2011

Power sanding the deck 10 Jun 2011

Planing the edge of the deck 2 Jun 2011

Figured out the deck 2 Jun 2011

Boat bulkheads 2 Jun 2011

Simulating iOS memory warnings 31 May 2011

Putting a UIButton in a UIToolbar 29 May 2011

How to allow closing a UIActionSheet by tapping outside it 29 May 2011

Finding the currently visible view in a UITabBarController 24 May 2011

Random Chef 17 May 2011

Centered UIButton in a navigation bar on the iphone 16 May 2011

Little Orchard 13 May 2011

Boat update 13 May 2011

How to get the current time in all time zones for the iphone / obj-c 12 May 2011

Design portfolio 10 May 2011

Tricks with grand central dispatch, such as objective-c's equivalent to setTimeout 9 May 2011

How to make an iphone view controller detect left or right swipes 5 May 2011

Centered section headers on a UITableView 5 May 2011

Christmas in may 4 May 2011

Finished trimming the boat (its floatable now!) and got some parts 29 Apr 2011

How to make a multiline label with dynamic text on the iphone and get the correct height 27 Apr 2011

Forcing an image size on the image in a table view cell on an iphone 20 Apr 2011

Git on the Mac 19 Apr 2011

Build a url query string in obj-c from a dictionary of params like jquery does 12 Apr 2011

Rendering a radial gradient on the iphone / objective-c 11 Apr 2011

Skinning the port side of the boat 8 Apr 2011

Skinning the side of the boat 5 Apr 2011

Sending a UDP broadcast packet in C / Objective-C 5 Apr 2011

How to talk to a unix socket / named pipe with python 4 Apr 2011

Skinning the bottom of the boat 31 Mar 2011

Service discovery using node.js and ssdp / universal plug n play 30 Mar 2011

Extremely simple python threading 29 Mar 2011

New rescue boat 26 Mar 2011

HttpContext vs HttpContextBase vs HttpContextWrapper 5 Nov 2010

Simple C# Wiki engine 30 Sep 2010

Simple way to throttle parts of your Asp.Net web app 29 Sep 2010

How to implement DES and Triple DES from scratch 4 Aug 2010

How to use sessions with Struts 2 30 Jul 2010

How to use Cookies in Struts 2 with ServletRequest and ServletResponse 30 Jul 2010

Using Quartz Scheduler in a Java web app (servlet) 27 Jul 2010

Javascript date picker that Doesn't Suck!(tm) 27 Jul 2010

Using Oracle XE with Hibernate 20 Jul 2010

A simple implementation of AES in Ruby from scratch 29 Jun 2010

Asp.Net Forms authentication to your own database 28 May 2010

AS2805 (like ISO8583) financial message parser in C# 7 May 2010

Ruby hex dumper 4 May 2010

Using Spring to manage Hibernate sessions in Struts2 (and other web frameworks) 13 Jan 2010

Emails in C#: Delivery and Read receipts / Attachments 12 Jan 2010

Using Java libraries in a C# app with IKVM 16 Dec 2009

Learning Java tutorial 27 Nov 2009

Using generic database providers with C# 17 Nov 2009

Scheduled task executable batch babysitter 29 Oct 2009

Working with query strings in Javascript using Prototype 30 Sep 2009

Still fighting with String.Format? 9 Sep 2009

How I'd build the next Google 24 Aug 2009

Getting IIS and Tomcat to play nicely with isapi_redirect 24 Aug 2009

Using the new ODP.Net to access Oracle from C# with simple deployment 11 Aug 2009

C# Cryptography - Encrypting a bunch of bytes 14 Jul 2009

Sorting enormous files using a C# external merge sort 10 Jul 2009

Reconciling/comparing huge data sets with C# 9 Jul 2009

Some keyboard-friendly DHTML tricks 10 Jun 2009

How to figure out what/who is connected to your SQL server 18 Mar 2009

Adding a column to a massive Sql server table 16 Mar 2009

Multithreading using Delegates in C# 10 Mar 2009

Using C# locks and threads to rip through a to-do list 6 Feb 2009

Using threads and lock in C# 3 Feb 2009

Compressing using the 7Zip LZMA algorithm in C# beats GZipStream 14 Jan 2009

MS Sql Server 2005 locking 17 Dec 2008

Simple Comet demo for Ruby on Rails 19 Nov 2008

Geocoding part 2 - Plotting postcodes onto a map of Australia with C# 24 Oct 2008

Using evolutionary algorithms to make a walkthrough for the light-bot game with C# 20 Oct 2008

How to tell when memory leaks are about to kill your Asp.Net application 16 Oct 2008

C# version of isxdigit - is a character a hex digit? 15 Sep 2008

Geocoding part 1 - Getting the longitude and latitude of all australian postcodes from google maps 26 Aug 2008

Converting HSV to RGB colour using C# 14 Aug 2008

Opening a TCP connection in C# with a custom timeout 11 Aug 2008

Oracle Explorer - a very simple C# open source Toad alternative 31 Jul 2008

Linking DigitalMars' D with a C library (Mongrel's HTTP parser) 23 Jun 2008

Connecting to Oracle from C# / Winforms / Asp.net without tnsnames.ora 16 Jun 2008

A simple server: DigitalMars' D + Libev 6 Jun 2008

Travelling from Rails 1 to Rails 2 9 Apr 2008

Online Rostering System 9 Apr 2008

DanceInforma 9 Apr 2008

Using RSS or Atom to keep an eye on your company's heartbeat 10 Nov 2007

Easy Integrated Active Directory Security in ASP.Net 24 Oct 2007