🛠️

Swift Package BuildToolPluginの不思議な仕様

2024/02/08に公開

少なくともXcode 14.0.1の頃はBuildToolPluginの中で生成した(.swift以外の)ファイルをBundle.moduleから取得することができなかったが、いつの間にか(Xcode 15.1.0以上?)取得することが可能になっている。

そのため、これまではXcodeBuildToolPluginを実装して.xcodeprojBuild Phases > Run Build Tool Plug-insでプラグインを実行することで、生成したファイルをBundle.mainから取得するようにしていたが、今はBuildToolPluginを直接Package.swiftで指定してプラグインを実行することで、Bundle.moduleから取得できるようになった。

BuildToolPluginの一例
import Foundation
import PackagePlugin

@main
struct MyCommandPlugin: BuildToolPlugin {
    // Package.swiftで.plugin指定して動くやつ
    // 生成したmy-output.plistはBundle.moduleから取得できる
    func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
        let executablePath = try context.tool(named: "my-command").path
        let outputPath = context.pluginWorkDirectory.appending(["Resources"])

        return [
            .buildCommand(
                displayName: "My Command",
                executable: executablePath,
                arguments: [outputPath.string],
                outputFiles: [
                    outputPath.appending(["my-output.plist"])
                ]
            )
        ]
    }
}

#if canImport(XcodeProjectPlugin)
import XcodeProjectPlugin

extension PrepareLicenseList: XcodeBuildToolPlugin {
    // .xcodeprojのBuilde PhasesのRun Build Tool Plug-insで動くやつ
    // 生成したmy-output.plistはBundle.mainから取得できる
    func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] {
        let executablePath = try context.tool(named: "my-command").path
        let outputPath = context.pluginWorkDirectory.appending(["Resources"])

        return [
            .buildCommand(
                displayName: "My Command",
                executable: executablePath,
                arguments: [outputPath.string],
                outputFiles: [
                    outputPath.appending(["my-output.plist"])
                ]
            ),
        ]
    }
}
#endif

.xcodeprojのBuilde PhasesのRun Build Tool Plug-insで動くやつの導入

Package.swift
// swift-tools-version: 5.8

import PackageDescription

let package = Package(
    name: "MyCommand",
    platforms: [.iOS(.v14)],
    products: [
        .plugin(
            name: "MyCommandPlugin",
            targets: ["MyCommandPlugin"]
        )
    ],
    targets: [
        .executableTarget(
            name: "my-command",
            path: "Sources/MyCommand"
        ),
        .plugin(
            name: "MyCommandPlugin",
            capability: .buildTool(),
            dependencies: [.target(name: "my-command")]
        )
    ]
)

これで、このプラグインを使っているプロジェクトの中でなら、Bundle.mainからmy-output.plistを取得できる。

Package.swiftで.plugin指定して動くやつの導入

上のMyCommandが別リポジトリで存在していて、それをアプリのローカルPackageで使うことを想定。

Package.swift
// swift-tools-version: 5.8

import PackageDescription

let package = Package(
    name: "MyAppPackages",
    platforms: [.iOS(.v14)],
    products: [
        .library(
            name: "ModuleA",
            targets: ["ModuleA"]
        )
    ],
    dependencies: [
        .package(url: "https://github.com/hoge/MyCommand.git", branch: "main")
    ],
    targets: [
        .target(
            name: "ModuleA",
            resources: [
                .process("Resources") // ⭐️
            ],
            plugins: [
                .plugin(name: "MyCommandPlugin", package: "MyCommand")
            ]
        )
    ]
)

これで、ModuleAの中でBundle.moduleからmy-output.plistを取得できる。

Swift Packageによるモジュールの中でBundle.moduleがコンパイル可能になるにはPackage.swiftの定義の中で、

resources: [
    .process("Resources")
],

resources: [
    .copy("Resources/MyFile.txt")
],

のようにresourcesを定義する必要がある(ディレクトリ名がResourcesである必要はない)。しかも空っぽのディレクトリを用意して指定してもダメで、何か一つはファイルがないとコンパイルが有効にならない。

ちなみに.process()だと中身を全てトップレベルで展開し、.copy()だとディレクトリ構造をキープしたまま展開する。.copy()の場合は具体的なファイルまで指定しないとビルドエラーになる。

一方、BuildToolPluginで生成したファイルのパスがBundle.moduleで見つかるようにするにはoutputFilesにファイルのパスを指定する必要があるが、

context.pluginWorkDirectory.appending(["Resources"])

のようにディレクトリの階層構造を組んでも実際にBundle.moduleで取得できるファイルパスは

"file:///Users/username/Library/Developer/CoreSimulator/Devices/XXXX-XXXX/data/Containers/Bundle/Application/YYYY-YYYY/MyApp.app/MyAppPackages_ModuleA.bundle/my-output.plist"

のようになり階層はなかったことになる。パスの指定でディレクトリ階層に忠実なのか、全てトップレベルになるのか塩梅がよくわからない。

とにかく、BuildToolPluginで生成した.swift以外のファイルをBundle.moduleで取得するには、ダミーのディレクトリとファイルを用意してresources: []に指定する必要があるが、ここで指定したディレクトリ名は.copy()で指定したか.process()で指定したかと関係なくなんでもいいらしい。

Discussion