Xcode build plugins

Helder Pinhal
Helder Pinhal
Jan 19 2024
Posted in Engineering & Technology

Taking Build Phases to the next level

Xcode build plugins

In the ever-evolving landscape of app development, staying ahead of the curve often requires embracing modular architectures. The benefits are numerous, but complexities can arise when tools struggle to keep pace. One such challenge that has surfaced is SwiftUI Previews in multi-module projects, particularly when leveraging Swift Packages that contain resources.

The SwiftUI Previews Dilemma

Imagine building an app with SwiftUI, breaking it into several isolated modules using Swift Packages, and introducing multiple levels of dependencies. A common issue emerges when SwiftUI Previews in one module, let's say AwesomeFeature, fail to work due to dependencies, such as DesignLibrary. The problem lies in loading resources like images from the bundle, causing a headache for developers.

Here's a simplified scenario:

// In DesignLibrary
public struct CustomView: View {
    var body: some View {
        // Fails in cross-module SwiftUI Previews
        Image("customImage", bundle: .module)
    }
}

Xcode generates a bundle extension for Swift Packages like this:

import class Foundation.Bundle
import class Foundation.ProcessInfo
import struct Foundation.URL

private class BundleFinder {}

extension Foundation.Bundle {
    /// Returns the resource bundle associated with the package Swift module.
    static let module: Bundle = {
        let bundleName = "DesignLibrary_DesignLibrary"

        let overrides: [URL]
        #if DEBUG
        // The 'PACKAGE_RESOURCE_BUNDLE_PATH' name is preferred since the expected value is a path. The
        // check for 'PACKAGE_RESOURCE_BUNDLE_URL' will be removed when all clients have switched over.
        // This removal is tracked by rdar://107766372.
        if let override = ProcessInfo.processInfo.environment["PACKAGE_RESOURCE_BUNDLE_PATH"]
                       ?? ProcessInfo.processInfo.environment["PACKAGE_RESOURCE_BUNDLE_URL"] {
            overrides = [URL(fileURLWithPath: override)]
        } else {
            overrides = []
        }
        #else
        overrides = []
        #endif

        let candidates = overrides + [
            // Bundle should be present here when the package is linked into an App.
            Bundle.main.resourceURL,

            // Bundle should be present here when the package is linked into a framework.
            Bundle(for: BundleFinder.self).resourceURL,

            // For command-line tools.
            Bundle.main.bundleURL,
        ]

        for candidate in candidates {
            let bundlePath = candidate?.appendingPathComponent(bundleName + ".bundle")
            if let bundle = bundlePath.flatMap(Bundle.init(url:)) {
                return bundle
            }
        }
        fatalError("unable to find bundle named DesignLibrary_DesignLibrary")
    }()
}

However, this generated code may miss crucial directories used by previews, leading to bundle loading issues.

Manual Solutions - Not Ideal

A manual solution involves creating custom bundle extensions for each module, addressing the missing directories. While effective, this approach contradicts the modular principle of avoiding repetition. This is where Xcode's build plugins come to the rescue.

Creating the Xcode Build Plugin

To resolve this challenge systematically, we can create a Xcode Build Plugin. This special type of package can generate the necessary code for each module during the build process.

1. Package.swift

Start by creating a Build Tool Plug-in via File > New > Package. The Package.swift manifest looks like this:

// swift-tools-version: 5.9

import PackageDescription

let package = Package(
    name: "PackageBundlePlugin",
    products: [
        .plugin(
            name: "PackageBundlePlugin",
            targets: ["PackageBundlePlugin"]
        ),
    ],
    targets: [
        .plugin(
            name: "PackageBundlePlugin",
            capability: .buildTool(),
            dependencies: ["PackageBundleGenerator"]
        ),
        .executableTarget(name: "PackageBundleGenerator"),
    ]
)

2. PackageBundlePlugin.swift

The source code of the plugin (Plugins/PackageBundlePlugin/PackageBundlePlugin.swift) acts as a bridge between Xcode's build pipeline and the executable:

import PackagePlugin

@main
struct PackageBundlePlugin: BuildToolPlugin {
    func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
        guard target.sourceModule != nil else { return [] }

        let output = context.pluginWorkDirectory.appending(subpath: "PackageBundle.g.swift")

        return [
            .buildCommand(
                displayName: "Generating package bundle extension for \(target.name)",
                executable: try context.tool(named: "PackageBundleGenerator").path,
                arguments: [
                    context.package.displayName,
                    target.name,
                    output,
                ],
                inputFiles: [],
                outputFiles: [output]
            )
        ]
    }
}

3. Executable - main.swift

Finally, the executable (Sources/PackageBundleGenerator/main.swift) generates the file containing the parameterized extension:

import Foundation

// Some insight into the problem and the implemented solution.
// https://developer.apple.com/forums/thread/664295

let arguments = ProcessInfo().arguments
let (packageName, targetName, output) = (arguments[1], arguments[2], arguments[3])

let generatedCode = """
import class Foundation.Bundle
import class Foundation.ProcessInfo
import struct Foundation.URL

private class BundleFinder {}

extension Bundle {
    public static let package: Bundle = {
        let bundleName = "\(packageName)_\(targetName)"

        let overrides: [URL]
        #if DEBUG
        // The 'PACKAGE_RESOURCE_BUNDLE_PATH' name is preferred since the expected value is a path. The
        // check for 'PACKAGE_RESOURCE_BUNDLE_URL' will be removed when all clients have switched over.
        // This removal is tracked by rdar://107766372.
        if let override = ProcessInfo.processInfo.environment["PACKAGE_RESOURCE_BUNDLE_PATH"]
            ?? ProcessInfo.processInfo.environment["PACKAGE_RESOURCE_BUNDLE_URL"] {
            overrides = [URL(fileURLWithPath: override)]
        } else {
            overrides = []
        }
        #else
        overrides = []
        #endif

        var candidates = overrides + [
            // Bundle should be present here when the package is linked into an App.
            Bundle.main.resourceURL,

            // Bundle should be present here when the package is linked into a framework.
            Bundle(for: BundleFinder.self).resourceURL,

            // For command-line tools.
            Bundle.main.bundleURL
        ]

        // FIX FOR PREVIEWS
        if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" {
            candidates.append(contentsOf: [
                // Bundle should be present here when running previews from a different package
                Bundle(for: BundleFinder.self).resourceURL?.deletingLastPathComponent().deletingLastPathComponent(),
                Bundle(for: BundleFinder.self).resourceURL?.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
            ])
        }

        for candidate in candidates {
            let bundlePath = candidate?.appendingPathComponent(bundleName + ".bundle")
            if let bundle = bundlePath.flatMap(Bundle.init(url:)) {
                return bundle
            }
        }

        fatalError("unable to find bundle named \(packageName)_\(targetName)")
    }()
}
"""

try generatedCode.write(to: URL(fileURLWithPath: output), atomically: true, encoding: .utf8)

Using the Build Plugin

With the build plugin in place, integrate it into your packages. For instance, referencing it in DesignLibrary would look like this:

// swift-tools-version: 5.9

import PackageDescription

let package = Package(
    name: "DesignLibrary",
    defaultLocalization: "en",
    platforms: [.iOS(.v16)],
    products: [
        .library(
            name: "DesignLibrary",
            targets: ["DesignLibrary"]
        ),
    ],
    dependencies: [
        .package(path: "../PackageBundlePlugin"),
    ],
    targets: [
        .target(
            name: "DesignLibrary",
            resources: [.process("Resources")],
            plugins: [
                .plugin(name: "PackageBundlePlugin", package: "PackageBundlePlugin"),
            ]
        ),
        .testTarget(
            name: "DesignLibraryTests",
            dependencies: ["DesignLibrary"]
        ),
    ]
)

Xcode will seamlessly execute the plugin during the build, generating the required extension for the custom .package bundle. Once you replace the .module bundle usages with the .package bundle, SwiftUI Previews should function flawlessly across modules, regardless of their dependencies.

Conclusion

In essence, Xcode build plugins provide the flexibility to enhance the build process according to your preferences. Whether it's code generation, linting tasks, or code analysis – the choice is yours.

As always, we hope you liked this article, and if you have anything to add, we are available via our Support Channel.

Keep up-to-date with the latest news