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

Thanks for reading! And if you want to get in touch, I'd love to hear from you: chris.hulbert at gmail.

Chris Hulbert

(Comp Sci, Hons - UTS)

iOS Developer (Freelancer / Contractor) in Australia.

I have worked at places such as Google, Cochlear, Assembly Payments, News Corp, Fox Sports, NineMSN, FetchTV, Woolworths, and Westpac, among others. If you're looking for help developing an iOS app, drop me a line!

Get in touch:
[email protected]
github.com/chrishulbert
linkedin
my resume



 Subscribe via RSS