How to integrate a Zig dependency into an Xcode or Tuist project

We got the following question in our Slack, so I’m answering it here for visibility.

From your Xcode or Tuist projects you can specify a dependency with an XCFramework that has been previously pre-compiled:

let target = Target(
 // Other attributes
  dependencies: [
    .xcframework(...)
  ] 
)

The caveat here is that whenever you change something in the Rust code, you have to re-generate the XCFramework outside of Xcode by running some script or command in the file system.

A tighter integration would require the following:

  1. Wrap tuist generate in your own script, generate.sh (or Mise task if you are using Mise) to compile the XCFramework if it’s absent and then generate the project.
  2. Then you can add a script build phase to your target that builds the same compilation command that you run outside of Xcode to compile the XCFramework. The input and output paths are accurate. Otherwise, Xcode’s build system might make unnecessary invocations of the XCFramework-compilation command.
1 Like

Thank you Pedro,

The solution I landed on was a bit too hacky, so i’ve decided to go with a similar approach to what you suggested.However, that caused a cyclic dependency, given the the outputFilePaths now equals to the path of the xcframework dependency. Xcode refused to build, I tried to circumvent it, but even if the outputFilePaths could be set it would use the xcframework that was generated from a previous run, despite it being a .pre target script, it linked prior to it running somehow.

Alternatively, I tried to have the build script run in a separate target that i then depend on, of course not depending on the xcframework there. That worked, but wasn’t ideal.

In the end, I’ve decided to modify the buildAction in the Scheme and add a ExecutableAction to the preActions.

    schemes: [
        .scheme(
            name: "REDACTED",
            buildAction: .buildAction(
                targets: [.target("REDACTED")],
                preActions: {
                    guard case let .string(zig) = Environment.zig else { return [] }
                    return [.executionAction(scriptText: "(cd $SRCROOT; \(zig) build ios)", target: "REDACTED")]
                }()
            )
        )
    ]

This unfortunately does run the build each time, but the build system I’m calling caches well so it’s fine in my case.

One minor nitpick with this approach, given Tuist takes a scriptText rather than something like a TargetScript which has an overload that allows me to pass a tool given I don’t know where the zig executable lives, I had to as you see above take it as an environment variable so TUIST_ZIG . It’s fine since I just have something equivalent to the generate.sh you suggested that does the following:

writeShellApplication {
      name = "xcgen";
      text = "(zig build ios; TUIST_ZIG=${lib.getExe zig} tuist generate)";
      runtimeInputs = [zig tuist];
}

Note, if your build system is expensive to call:

The approach I mentioned wasn’t ideal, where you have a separate target that you depend on call the build system, is better in that case. Since you can set the inputFilePaths and outputFilePaths as Pedro mentioned. To only call the build system when the files change.

A better approach:

        .target(
            name: "Prelude",
            destinations: .iOS,
            product: .staticFramework,
            bundleId: "com.example.Libs.Prelude",
            deploymentTargets: .iOS("12.0"),
            headers: .headers(public: ["src/prelude/*.h"]),
            scripts: [
                .pre(
                    tool: "zig",
                    arguments: [
                        "build",
                        "-Dtarget=aarch64-ios$LLVM_TARGET_TRIPLE_SUFFIX",
                        #"-Doptimize=$([ "$CONFIGURATION" == "Release" ] && echo "ReleaseSafe" || echo "Debug")"#
                    ],
                    name: "Run zig build",
                    inputPaths: ["$(SRCROOT)/src/prelude/**/*.zig"],
                    outputPaths: ["$(SRCROOT)/zig-out/lib/libprelude.a"],
                    basedOnDependencyAnalysis: true
                ),
                .post(
                    script:
                        "cp $SRCROOT/zig-out/lib/libprelude.a $SCRIPT_OUTPUT_FILE_0",
                    name: "Copy libprelude.a",
                    inputPaths: ["$(SRCROOT)/zig-out/lib/libprelude.a"],
                    outputPaths: ["$(BUILT_PRODUCTS_DIR)/$(EXECUTABLE_PATH)"],
                    basedOnDependencyAnalysis: true
                ),
            ],
            // For good measure, preventing the generation of any executable.
            settings: .settings(base: ["VERSIONING_SYSTEM": "None"])
        ),

You can also do the optimize stuff better if you have your own xcconfig, but this works pretty well.

a really simple build.zig would also work here as opposed to having to generate an xcframework

    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const prelude = b.addStaticLibrary(.{
        .name = "prelude",
        .root_source_file = b.path("src/prelude/prelude.zig"),
        .optimize = optimize,
        .target = target,
    });

    prelude.bundle_compiler_rt = true;

    prelude.installHeadersDirectory(b.path("src/prelude/include"), &.{}, .{});

    b.installArtifact(prelude);

There’s no longer a need for a generate.sh step either.

1 Like

Thanks for sharing it @huwaireb. Why is the step to copy the binary into $(BUILT_PRODUCTS_DIR)/$(EXECUTABLE_PATH) necessary?

Right now, the generated static library is produced in the project directory, relativeToManifest under zig-out/lib/libprelude.a. Xcode doesn’t know it’s the final static library, there’s no option to specify it is.

The step to copy it to $(BUILT_PRODUCTS_DIR)/$(EXECUTABLE_PATH), allows us to do so. It places it in XCODE_PRODUCT_DIR/Prelude.framework/Prelude for example. Which the info.plist states is the path to our static library. Therefore any consumer of the framework will expect it to exist there.

if we omitted this step, consumers of this framework will fail to build.

To avoid the post script step that copies, one can do the following

                .pre(
                    tool: "zig",
                    arguments: [
                        "build",
                        "prelude",
                        "-Dtarget=aarch64-ios$LLVM_TARGET_TRIPLE_SUFFIX",
                        #"-Doptimize=$([ "$CONFIGURATION" == "Release" ] && echo "ReleaseSafe" || echo "Debug")"#,
                        "--prefix-lib-dir", 
                        #""$BUILT_PRODUCTS_DIR/$EXECUTABLE_FOLDER_PATH""#,
                    ],
                    name: "Run zig build",
                    inputPaths: ["src/prelude/**/*.zig"],
                    outputPaths: ["$(BUILT_PRODUCTS_DIR)/$(EXECUTABLE_PATH)"],
                    basedOnDependencyAnalysis: true
                )

and in their build script

    const prelude_step = b.step("prelude", "Build's only the prelude static library");

    const prelude = b.addStaticLibrary(.{
        .name = "prelude",
        .root_source_file = b.path("src/prelude/prelude.zig"),
        .optimize = optimize,
        .target = target,
    });

    prelude.bundle_compiler_rt = true;

    // REASON(ref: https://developer.apple.com/documentation/bundleresources/information-property-list/cfbundleexecutable#Discussion):
    // > For a framework, it’s the shared library framework and must have the same name as the framework but without
    // > the .framework extension.
    const install_prelude = if (target.result.os.tag == .ios) b: {
        // NOTE: The header file is omitted on purpose, as Xcode generates a modulemap
        // so we need to directly depend on the header as opposed to copy it in.
        const install_lib = b.addInstallFileWithDir(prelude.getEmittedBin(), .lib, "Prelude");
        install_lib.step.dependOn(&prelude.step);
        break :b &install_lib.step;
    } else b: {
        prelude.installHeadersDirectory(b.path("src/prelude"), &.{}, .{});
        break :b &b.addInstallArtifact(prelude, .{}).step;
    };

    prelude_step.dependOn(install_prelude);

where, in this case, zig will produce a zig-out/lib/Prelude if the -Dtarget=aarch74-ios and our zig build call with --prefix-lib-dir tells it to install the sources in the lib directory in the provided path.

:purple_heart: I quite enjoyed reading through your iterations until the most simple integration possible.

1 Like