MijoCoder

Manipulate an Xcode project via script

One way to manipulate an Xcode project is to use XcodeProj library. It’s written in Swift and lightly abstracts a pbxproj file. I’m saying lightly because you need to be explicit about your intentions. If you want to replicate an action in Xcode, like deletion of a target, it’s not enough to just call:

xcodeproj.pbxproj.delete(object: target)

You also need to delete all other objects that depend on the target.

The safest way of using the library is to compare the diff between an action done in Xcode and the diff of the equivalent action in a script. That can be time consuming, so I’ll share a few code snippets that enabled me to automate a part my release process on one project. To achieve some other goals, maybe you can find a solution in the Issues section of project’s Github page or in the author’s blog.

// How to remove a target
func removeTheTargetFromPbxproj() {
    guard let target = xcodeproj.pbxproj.nativeTargets.first(where: { $0.name = "TheTarget" }) else { return }
    target.buildPhases.forEach { buildPhase in
        if let buildFiles = buildPhase.files {
            buildFiles.forEach { buildFile in
                for resourceBuildPhase in xcodeproj.pbxproj.resourcesBuildPhases {
                    if let idx = resourceBuildPhase.files?.firstIndex(of: buildFile ) {
                        resourceBuildPhase.files?.remove(at: idx)
                    }
                }
                xcodeproj.pbxproj.delete(object: buildFile)
            }
        }
        xcodeproj.pbxproj.delete(object: buildPhase)
    }
    
    if let configurationList = target.buildConfigurationList {
        xcodeproj.pbxproj.delete(object: configurationList)
    }
    
    xcodeproj.pbxproj.rootObject?.targets.removeAll(where: { $0 == target })
    xcodeproj.pbxproj.delete(object: target)
}

// How to remove a group with its children
func removeTheGroup() throws {
    guard let group = xcodeproj.pbxproj.groups.first(where: { $0.path == "pathToDelete" }) else { return }
    
    for child in group.children {
        if let buildFile = xcodeproj.pbxproj.buildFiles.first(where: { $0.file == child }) {
            for resourceBuildPhase in xcodeproj.pbxproj.resourcesBuildPhases {
                if let idx = resourceBuildPhase.files?.firstIndex(of: buildFile ) {
                    resourceBuildPhase.files?.remove(at: idx)
                }
            }
            xcodeproj.pbxproj.delete(object: buildFile)
        }
        
        xcodeproj.pbxproj.delete(object: child)
    }
    if let parent = group.parent as? PBXGroup, let idx = parent.children.firstIndex(of: group) {
        parent.children.remove(at: idx)
    }
    
    xcodeproj.pbxproj.delete(object: group)
    try deleteElementFromDisk(element: group)
}

// How to remove files
func removeExperimentalConfigFiles() throws {
    let groupsContaingConfigsToDelete = Set<String>(["Release", "Debug", "Common"])
    
    for group in xcodeproj.pbxproj.groups {
        if let path = group.path, groupsContaingConfigsToDelete.contains(path),
           let parentPath = group.parent?.path, parentPath == "Config" {
            
            for file in group.children {
                guard let path = file.path, !path.hasPrefix("Experimental") else { continue }
                if let idx = group.children.firstIndex(of: file) {
                    group.children.remove(at: idx)
                }
                xcodeproj.pbxproj.delete(object: file)
                try deleteElementFromDisk(element: file)
            }
        }
    }
}

// How to add a framework
func addFramework(name: String, to target: PBXTarget) throws {
    guard let frameworksPhase = target.buildPhases.first(where: { $0.name() == "Frameworks" }) as? PBXFrameworksBuildPhase else { return }
    guard let group = xcodeproj.pbxproj.groups.first(where: { $0.name == "Frameworks" }) else { return }
    
    let embedFrameworksPhase = {
        if let phase = target.buildPhases.first(where: { $0.name() == "Embed Frameworks" }) {
            return phase
        } else {
            let phase = PBXCopyFilesBuildPhase(
                dstPath: "",
                dstSubfolderSpec: .frameworks,
                name: "Embed Frameworks"
            )
            
            xcodeproj.pbxproj.add(object: phase)
            target.buildPhases.append(phase)
            
            return phase
        }
    }()
    
    let ref = PBXFileReference(sourceTree: .group, name: "\(name).xcframework", path: "../some_path/\(name).xcframework")
    xcodeproj.pbxproj.add(object: ref)
    group.children.append(ref)
    _ = try frameworksPhase.add(file: ref)
    let buildFile = try embedFrameworksPhase.add(file: ref)
    buildFile.settings = ["ATTRIBUTES": ["CodeSignOnCopy", "RemoveHeadersOnCopy"]]
}

// How to rearrange build phases
func makeCutomBuildPhasesExecuteLast() {
    for target in xcodeproj.pbxproj.nativeTargets {
        ["dSYMs upload script", "Copy licences"]
            .forEach { name in
                guard let idx = target.buildPhases.firstIndex(where: { $0.name() == name }) else { return }
                let phase = target.buildPhases[idx]
                target.buildPhases.remove(at: idx)
                target.buildPhases.append(phase)
            }
    }
}
Tagged with: