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:
Source
folderUnitTests
folderUITests
folderThe 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:
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..graphql
to see an example of files that are skipped.test
in their name in the Source folder are added to the unit test target, not the normal target.DEVELOPMENT_TEAM
to your team ID.TARGETED_DEVICE_FAMILY
is set to iphone only, you might want to change this to include the iPad.apolloPhase
to see how you can add your own shell scripts.buildNumberPhase
to see it.*.xcodeproj
) and run ./ProjectGenerator.swift
whenever appropriate, eg you’ve added files.#!/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"
let frameworksFolder = "Frameworks"
// 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: "https://github.com/firebase/firebase-ios-sdk.git",
requirement: [
"kind": "upToNextMajorVersion",
"minimumVersion": "8.4.0",
],
products: ["FirebaseAnalyticsWithoutAdIdSupport", "FirebaseCrashlytics", "FirebaseRemoteConfig"]),
ConfigPackage(id: .next(.package),
url: "https://github.com/googleanalytics/google-tag-manager-ios-sdk.git",
requirement: [
"kind": "upToNextMajorVersion",
"minimumVersion": "7.0.0",
],
products: ["GoogleTagManager"]),
ConfigPackage(id: .next(.package),
url: "https://github.com/airbnb/lottie-ios.git",
requirement: [
"kind": "upToNextMajorVersion",
"minimumVersion": "3.3.0",
],
products: ["Lottie"]),
]
/********************* Models *********************/
struct WhiteLabel {
let name: String
let plist: String
let entitlements: 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).
// aka PBXBuildFile
struct BuildFile {
let id: ID
let fileRef: String?
let productRef: String?
let settings: [String: Any]?
}
// 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)
case framework = "wrapper.framework"
case xcFramework = "wrapper.xcframework"
case folderReference = "folder" // Folder reference (blue icon in Xcode).
}
extension LastKnownFileType {
var isDirectoryTreatedLikeFile: Bool {
switch self {
case .swift, .storyboard, .plist, .file: return false
case .assets, .framework, .xcFramework, .folderReference: return true
}
}
}
// This is the canonical object for a given file's existence: 1 of these records per file.
// aka: PBXFileReference
struct File {
let id: ID
let lastKnownFileType: LastKnownFileType?
let explicitFileType: ExplicitFileType?
let includeInIndex: Bool?
let path: String // Even though this is called 'path' to match Xcode's file format, this is really just the file name eg "Foo.swift".
let pathRelativeToRoot: String // Eg "Source/Foo/Bar.swift".
let sourceTree: String // Eg "<group>" or "BUILT_PRODUCTS_DIR".
}
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"
case copyFiles = "PBXCopyFilesBuildPhase"
}
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]
var name: String?
var dstPath: String?
var dstSubfolderSpec: Int?
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] {
var dict: [String: Any] = [
"isa": category.rawValue,
"buildActionMask": buildActionMask,
"files": files.map({ $0.id }),
"runOnlyForDeploymentPostprocessing": runOnlyForDeploymentPostprocessing,
]
if let dstPath = dstPath {
dict["dstPath"] = dstPath
}
if let dstSubfolderSpec = dstSubfolderSpec {
dict["dstSubfolderSpec"] = dstSubfolderSpec
}
if let name = name {
dict["name"] = name
}
return dict
}
}
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
}
if let settings = settings {
dict["settings"] = settings
}
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
case "framework": return .framework
case "xcframework": return .xcFramework
case "folderreference": return .folderReference
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.
/// Where the parent group is 'WhiteLabels', if it finds any groups with a name matching "Foo" or "FooAndBarAndBaz", it converts that to 'Foo', 'Bar', 'Baz', and if any of those match whiteLabel, it allows it.
func allFilesRecursively(whiteLabel: String, parentGroupName: String? = nil) -> [File] {
var files: [File] = []
for child in children {
switch child {
case .group(let g):
if let path = g.path, parentGroupName == "WhiteLabels" {
let whiteLabels = path.components(separatedBy: "And")
guard whiteLabels.contains(whiteLabel) else { continue }
}
files.append(contentsOf: g.allFilesRecursively(whiteLabel: whiteLabel, parentGroupName: g.path))
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
case framework
case unitTestResource
}
// Figure out which file goes in which target/phase.
func determineFilePurposes(source: Group, unitTests: Group, uiTests: Group, frameworks: Group, whiteLabelName: String) -> [String: FilePurpose] {
// Combine all files.
enum Location {
case source
case unit
case ui
case frameworks
}
struct FileLocation {
let file: File
let location: Location
}
var files: [FileLocation] = []
files.append(contentsOf: source .allFilesRecursively(whiteLabel: whiteLabelName).map({ FileLocation(file: $0, location: .source) }))
files.append(contentsOf: unitTests .allFilesRecursively(whiteLabel: whiteLabelName).map({ FileLocation(file: $0, location: .unit) }))
files.append(contentsOf: uiTests .allFilesRecursively(whiteLabel: whiteLabelName).map({ FileLocation(file: $0, location: .ui) }))
files.append(contentsOf: frameworks.allFilesRecursively(whiteLabel: whiteLabelName).map({ FileLocation(file: $0, location: .frameworks) }))
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 }
guard !lowerPath.hasSuffix(".old") else { continue }
guard !lowerPath.hasSuffix(".entitlements") else { continue } // Dont include xcode entitlements files.
let pathRelativeToRoot = file.file.pathRelativeToRoot
let pathContainsTest = pathRelativeToRoot.contains("Test") || pathRelativeToRoot.contains("test") // So eg "getEstimatedFoo" isn't considered a 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, _, true):
purpose = .unitTest
case (.unit, _, false):
purpose = .unitTestResource
case (.ui, _, _):
purpose = .uiTest
case (.frameworks, _, _):
purpose = .framework
}
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, projectRoot: 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 }
guard content.lastPathComponent != ".git" else { continue } // Ignore submodules' git folder.
let values = try! content.resourceValues(forKeys: [.isDirectoryKey])
let isDirectory = values.isDirectory ?? false
let type = LastKnownFileType.from(pathExtension: content.pathExtension)
let pathRelativeToRoot = content.absoluteString.replacingOccurrences(of: projectRoot.absoluteString, with: "")
if isDirectory && !type.isDirectoryTreatedLikeFile { // Assets/frameworks/folder references are special directories that are considered a 'file' like a mac bundle.
let grandChildren = groupChildren(url: content, projectRoot: projectRoot)
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: type,
explicitFileType: nil,
includeInIndex: nil,
path: content.lastPathComponent,
pathRelativeToRoot: pathRelativeToRoot,
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,
"DEBUG_INFORMATION_FORMAT": "dwarf-with-dsym",
"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.1,
"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",
"OTHER_LDFLAGS": "-Wl,-ObjC", // https://firebase.google.com/docs/ios/swift-package-manager#analytics
]
// The project-level build settings that are different for debug vs common.
let projectDebugExtraBuildSettings: [String : Any] = [
"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] = [
"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"],
"FRAMEWORK_SEARCH_PATHS": ["$(inherited)", "$(PROJECT_DIR)/Frameworks"],
"PRODUCT_BUNDLE_IDENTIFIER": whiteLabel.bundleId,
"CODE_SIGN_ENTITLEMENTS": whiteLabel.entitlements,
]
}
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(fileURLWithPath: projectRootFolder)
let sourceURL = URL(fileURLWithPath: sourceFolder, relativeTo: projectRootURL)
let unitTestsURL = URL(fileURLWithPath: unitTestsFolder, relativeTo: projectRootURL)
let uiTestsURL = URL(fileURLWithPath: uiTestsFolder, relativeTo: projectRootURL)
let frameworksURL = URL(fileURLWithPath: frameworksFolder, relativeTo: projectRootURL)
let sourceGroup = Group(id: sourceGroupID, children: groupChildren(url: sourceURL, projectRoot: projectRootURL), path: sourceFolder, name: nil)
let unitTestsGroup = Group(id: unitTestsGroupID, children: groupChildren(url: unitTestsURL, projectRoot: projectRootURL), path: unitTestsFolder, name: nil)
let uiTestsGroup = Group(id: uiTestsGroupID, children: groupChildren(url: uiTestsURL, projectRoot: projectRootURL), path: uiTestsFolder, name: nil)
let frameworksGroup = Group(id: frameworksGroupID, children: groupChildren(url: frameworksURL, projectRoot: projectRootURL), path: frameworksFolder, 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",
pathRelativeToRoot: whiteLabel.name + ".app",
sourceTree: "BUILT_PRODUCTS_DIR")
let unitTestsFile = File(id: .next(.file),
lastKnownFileType: nil,
explicitFileType: .bundle,
includeInIndex: false,
path: whiteLabel.name + "UnitTests.xctest",
pathRelativeToRoot: 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",
pathRelativeToRoot: 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: [
"# We're (after 1.0) making our build number's yyyymmdd.hhmm.xxxx",
"# Where xxxx is the decimal representation of the first 3 hex digits of the git hash.",
"# See: https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundleversion",
"if [[ $CONFIGURATION == Release ]]; then",
" YYYYMMDDHHMM=`TZ=Singapore date \"+%Y%m%d.%H%M\"`",
" GIT_HASH_DECIMAL=$(printf \"%d\" 0x$(git rev-parse HEAD | head -c 3))",
" BUNDLE_VERSION=\"$YYYYMMDDHHMM.$GIT_HASH_DECIMAL\"",
" /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",
])
let crashlyticsPhase = ShellScriptBuildPhase(id: .next(.buildPhase),
name: "Crashlytics debug symbols upload",
shellScript: [
"exit 0 # Crashlytics is disabled for now because it times out half the time.",
"if [[ $CONFIGURATION == Release ]]; then",
" echo \"Uploading dSYMs\"",
" if `find ~/Library/Developer/Xcode/DerivedData -name upload-symbols | head -n 1` -d -gsp \"${CODESIGNING_FOLDER_PATH}\"/GoogleService-Info.plist -p ios `find \"${BUILD_DIR}/..\" -iname \"*.app.dSYM\"`",
" then",
" echo \"Uploaded successfully\"",
" else",
" echo \"Failed to upload\"",
" if [[ $PRODUCT_NAME == *\"Dev\" ]]; then",
" echo \"Ignoring errors in Dev\"",
" true # Ignore earlier failures in dev.",
" else",
" exit 1 # Stop the build.",
" fi",
" fi",
"else",
" echo \"Not uploading dSYMs, this isn't a release build.\"",
"fi",
])
// This is where you'd add extra build phases, and add them to the Target's buildPhases array parameter too.
var mainSources = BuildPhase(id: .next(.buildPhase), category: .sources, files: [], name: nil, dstPath: nil, dstSubfolderSpec: nil)
var mainFrameworks = BuildPhase(id: .next(.buildPhase), category: .frameworks, files: [], name: nil, dstPath: nil, dstSubfolderSpec: nil)
var mainResources = BuildPhase(id: .next(.buildPhase), category: .resources, files: [], name: nil, dstPath: nil, dstSubfolderSpec: nil)
var mainEmbedFrameworks = BuildPhase(id: .next(.buildPhase), category: .copyFiles, files: [], name: "Embed Frameworks", dstPath: "", dstSubfolderSpec: 10)
var unitSources = BuildPhase(id: .next(.buildPhase), category: .sources, files: [], name: nil, dstPath: nil, dstSubfolderSpec: nil)
let unitFrameworks = BuildPhase(id: .next(.buildPhase), category: .frameworks, files: [], name: nil, dstPath: nil, dstSubfolderSpec: nil)
var unitResources = BuildPhase(id: .next(.buildPhase), category: .resources, files: [], name: nil, dstPath: nil, dstSubfolderSpec: nil)
var uiSources = BuildPhase(id: .next(.buildPhase), category: .sources, files: [], name: nil, dstPath: nil, dstSubfolderSpec: nil)
var uiFrameworks = BuildPhase(id: .next(.buildPhase), category: .frameworks, files: [], name: nil, dstPath: nil, dstSubfolderSpec: nil)
let uiResources = BuildPhase(id: .next(.buildPhase), category: .resources, files: [], name: nil, dstPath: nil, dstSubfolderSpec: nil)
// Figure out the purpose of all files in all groups.
let purposes = determineFilePurposes(source: sourceGroup, unitTests: unitTestsGroup, uiTests: uiTestsGroup, frameworks: frameworksGroup, whiteLabelName: whiteLabel.name)
for (file, purpose) in purposes { // Add them to the appropriate build phases.
switch purpose {
case .source:
mainSources.files.append(BuildFile(id: .next(.buildFile), fileRef: file, productRef: nil, settings: nil))
case .framework:
mainFrameworks.files.append(BuildFile(id: .next(.buildFile), fileRef: file, productRef: nil, settings: nil))
mainEmbedFrameworks.files.append(BuildFile(id: .next(.buildFile), fileRef: file, productRef: nil, settings: [
"ATTRIBUTES": ["CodeSignOnCopy", "RemoveHeadersOnCopy"],
]))
case .resource:
mainResources.files.append(BuildFile(id: .next(.buildFile), fileRef: file, productRef: nil, settings: nil))
case .unitTest:
unitSources.files.append(BuildFile(id: .next(.buildFile), fileRef: file, productRef: nil, settings: nil))
case .unitTestResource:
unitResources.files.append(BuildFile(id: .next(.buildFile), fileRef: file, productRef: nil, settings: nil))
case .uiTest:
uiSources.files.append(BuildFile(id: .next(.buildFile), fileRef: file, productRef: nil, settings: 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, settings: nil))
uiFrameworks.files.append(BuildFile(id: .next(.buildFile), fileRef: nil, productRef: product.id, settings: nil))
}
let mainTarget = Target(id: .next(.target),
buildConfigurationList: mainTargetConfigList,
buildPhases: [buildNumberPhase, mainSources, mainFrameworks, mainResources, mainEmbedFrameworks, crashlyticsPhase],
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 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.
(Comp Sci, Hons - UTS)
Software Developer (Freelancer / Contractor) in Australia.
I have worked at places such as Google, Cochlear, Assembly Payments, News Corp, Fox Sports, NineMSN, FetchTV, Coles, Woolworths, Trust Bank, 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