Integration of AppIntents to a Project that uses Bazel Build System

Hey guys, it’s Cyan from the Mercoin iOS Team. This time, I would like to write about my experience integrating the new AppIntents framework to an iOS project that uses a Bazel build system. Followed by the actual implementation guide as well as a brief comparison of the new AppIntents vs the old Intents frameworks.

Firstly, what is this new AppIntents framework? It’s a new framework that serves as a replacement for the old framework Intents. With this framework, it allows users to create shortcuts in the Shortcuts app that can later be executed with Siri commands, making it an impressively useful and convenient feature.

Let’s get started!

Story Time

To begin with, we were tasked with adding an additional item to the Shortcuts app using the new AppIntents framework.

When you check tutorials online on how to use AppIntents, it is fairly straightforward:

  1. Create a .swift file
  2. Add import AppIntents
  3. Create a struct that conforms to AppIntent
  4. Run the app and you should be able to see the created AppIntent on the Shortcuts app.

It is straightforward, for a project that uses the default Xcode build system. However, for a project that uses the Bazel build system, it is a whole different story. As some of you might not yet know, Mercari’s iOS app uses Bazel. If you’re curious, you could read this article by Aoyama-san.

As Bazel doesn’t have too much documentation on the internet, we couldn’t easily find references about how to use AppIntents with Bazel.

We’ve tried everything: searching the web, using Cursor AI, using Mercari’s internal AI tool, but it took quite some time before we finally could find how to do it by referencing from this commit:

In this commit, we’ve noticed that there are 2 sample BUILD files under the test/starlark_tests directory:

  1. /targets_under_test/ios/BUILD
  2. /resources/BUILD

If you’re wondering what a BUILD file does, basically it’s like a configuration file of a package/module. You would add dependencies here, as well as add additional info if you’re using unit tests, app extensions, app intents, and many more. You could refer to this link if you’d like to know more about BUILD files.

Since these BUILD files are under the test folder, we thought that these could be the sample BUILD files for when you’re actually trying to integrate AppIntent into a Bazel-based project. One would be the BUILD file for the main app, and one for the module containing the AppIntent files.

We’ve checked the contents of both BUILD files, and we’ve deduced that the BUILD file that has app_intents as one of the parameters, could possibly be the sample BUILD file for the main app.

So with that said, we’ve proceeded with this:

  1. Created a separate module from the Mercari main app, and named it MercariAppIntents
  2. Added a BUILD file for MercariAppIntents while referencing the BUILD file from resources
  3. Updated the BUILD file for the Mercari main app while referencing the BUILD file from targets_under_test/ios since this BUILD file contains the app_intents parameter

And then when trying to run the command that generates the Xcode project, we are faced with this error message:

Error in fail: Target '@@//Projects/Products/Mercari/Apps/MercariAppIntents:MercariAppIntents' does not depend on the AppIntents SDK framework. Found the following SDK frameworks: []

which is basically this error from Bazel’s code:

We’ve searched about this error message for hours, but it seems that no one has made any blog/writing about this error:

And yeah, asking AI didn’t help either. So, as we’ve realized that this is not something that searching through the Internet could resolve, we’ve tried some solutions by doing trial-and-error.

At first, we tried adding linkopts to the BUILD file of MercariAppIntents.

swift_library(
    name = "MercariAppIntents",
    linkopts = ["-framework,AppIntents"],
)

The reason is that the module for Widget also uses that parameter so we thought that it might work for AppIntents as well. Unfortunately, it still shows the same error.

For the second try, we tried adding linkopts to the BUILD file of the Mercari main app.

ios_application(
    name = "Mercari",
...
    linkopts = [
        "-framework,AppIntents"
    ]
)

But this also didn’t work.

After spending some more hours trying to look up the internet for some information, we gave up and just asked the Architecture team for help.

The Architecture team provided us with this solution:
Add either of these to the BUILD file of MercariAppIntents:

linkopts = [
    "-Wl,-framework,AppIntents"
],
linkopts = [
    "-framework", "AppIntents",
],

And finally, both actually resolved the error during project generation. Hooray!

However, the first one didn’t show the new AppIntents on the Shortcuts app but the second one did. During this time, I looked online on what linkopts actually is for, but I think everyone else might agree with me, Bazel’s documentation isn’t very helpful and was kinda cryptic. So, I just left it there, and just assumed that linkopts is to allow a module to import a native Apple SDK as a dependency. And that the format is as above. I just took it as a “new lesson learned for the day” and moved on.

So that will be the end of the story on how we manage to make AppIntents work on a project that uses the Bazel build system. It wasn’t smooth as we expected it to be, but with the help of multiple people (special thanks to Martin-san and Aoyama-san), we’ve managed to pull it through.

Btw, this is the thread when we were trying different approaches for this task:

Actual Implementation Guide

To actually integrate AppIntents on a project that uses Bazel, it is pretty simple.

Create a new module that will contain your AppIntent structs.

In this module’s BUILD file, you should have something like this:

swift_library(
    name = "MercariAppIntents",
    linkopts = [
        "-framework",
        "AppIntents",
    ],
)

On your main app’s BUILD file, you would need to add app_intents that should reference the new module you’ve just created, as well as add it to deps so that you would see the module when you open Xcode.

app_intents = [
    "//Projects/Products/Mercari/Apps/MercariAppIntents",
],
...
deps = [
    ...
    "//Projects/Products/Mercari/Apps/MercariAppIntents",
    ...
]

Once you’ve done that, you can proceed to generate your Xcode project.

In your new module, you can now add a file that will contain your AppIntent struct.

import AppIntents
import UIKit

internal struct YourAppIntent: AppIntent {
    // These are part of the AppIntent conformance
    static let title: LocalizedStringResource = "title"
    static let description = IntentDescription("description")

    // Set this to true so that your app will be opened when intent is run
    static let openAppWhenRun: Bool = true

    // This is also part of the AppIntent conformance
    @MainActor func perform() async throws -> some IntentResult {
        // You can put any custom URL you want here 
        await UIApplication.shared.open("yourapp://app/home")

        // Just return it like below
        return .result()
    }
}

Once you’ve run your project, you should be able to see your new intent on the Shortcuts app. And it should display the title and description we have set above.

If you would want to use Localization for your AppIntent, you can do so by creating Localizable.strings. Do note that you need to have the file to be exactly Localizable.strings as that would be the one that will be recognized by the AppIntent files.

For example, if you have set the Localizable.strings as below:

// ...en.lproj/Localizable.strings  
"title" = "Test Title";
"description" = "Test Description";

// ...ja.lproj/Localizable.strings 
"title" = "タイトル";
"description" = "内容";

You’d have something like this:

So basically, if you have a Localizable.strings on your module, the string you write here will basically be the key on your Localizable.strings file.

static let title: LocalizedStringResource = "title" // used as key
static let description = IntentDescription("description") // used as key

If you don’t have a Localizable.strings, it would just basically display that key as-is.

New AppIntents vs Old Intents

There are some key differences between the old Intents and the new AppIntents. Using the old Intents framework, to define custom intents, you would actually need to create a .intentdefinition file and input the values for your title and description on the file that looks like this:

I mean it is easy to comprehend and input values, but the problem is when you want to localize your Intent. How it is localized is done like this:

First, you have to look for the key for your title or your description. Look for your .intentdefinition file and Open As → Source Code. Once you see it like below, and take note of the key for your title or your description.

And then, on your Intents.strings, you would need to use the key above to use as key for your localization file.

"6TIN6s" = "Title";

Just from this, seeing that non-human readable key, you’d wish to move to using the new AppIntents framework already.

For AppIntents, on the other hand, as shown on the previous section, you would basically need these 2 files only:

A .swift file containing your AppIntent:

internal struct YourAppIntent: AppIntent {
    static let title: LocalizedStringResource = "title"
    static let description = IntentDescription("description")

    @MainActor
    func perform() async throws -> some IntentResult {
        ...
        return .result()
    }
}

Then a Localizable.strings, that has content like this:

"title" = "...";
"description" = "...";

With just these two, AppIntents is leaps ahead of the old Intents framework.

Additionally, you could do other stuff like AppShortcutsProvider. This is a sample code in how to use it:

import AppIntents

struct ShortcutsProvider: AppShortcutsProvider {
    static var appShortcuts: [AppShortcut] {
        AppShortcut(
            intent: SampleIntent(),
            phrases: ["Sample \(.applicationName)"],
            shortTitle: "title 1",
            systemImageName: "cup.and.saucer.fill"
        )
        AppShortcut(
            intent: SampleIntent2(),
            phrases: ["Sample 2 \(.applicationName)"],
            shortTitle: "title 2",
            systemImageName: "cup.and.saucer.fill"
        )
        AppShortcut(
            intent: SampleIntent3(),
            phrases: ["Sample 3 \(.applicationName)"],
            shortTitle: "title 3",
            systemImageName: "cup.and.saucer.fill"
        )
    }
}

Which could display something like this:

New Shortcut for the Mercari App

After the completion of this research on how to use AppIntent on a project using Bazel, we’ve successfully added a new shortcut (using the new AppIntents) for the Mercari iOS app. Previously, there was an already existing shortcut (using the old Intents SDK) from the Merpay team. This new shortcut allows users to go directly to the bitcoin chart screen, the screen which the Mercoin team mainly handles.

Setting up the shortcut with a custom name "Open MC"

You could have something like this:

As you can see from the video, with Siri Shortcuts you could also use Siri to open the shortcut by just saying the custom name you set to the shortcut.

Conclusion

So yeah, that’s it!

Hopefully, you’ve just successfully added AppIntents to your iOS project with Bazel. If you already have existing custom intents using Intents framework on your project, you could actually still see them even after you’ve added newer intents with AppIntents. It was a good thing that these two could co-exist with one another.

I wished there was a blog/resource like this when I was working on this task, but unfortunately there wasn’t so I was hoping I could be of some help to other developers who would face this problem as well.

Thank you so much for staying!

I hope you enjoyed reading this article 🙂

References

The next article will be by @keitasuzuki. Please look forward to it!

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加