Skip to content
C.W.K.
Stream
Lesson 03 of 06 · published

App Intents: Siri, Shortcuts and the Action Button Without Opening the App

~17 min · beyond-the-app, app-intents, shortcuts, siri, testing

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"Behaviour left inside perform() is behaviour no test can reach."

A Door That Needs No Screen

An App Intent is an action your app declares in Swift so the system can run it: from Siri, from the Shortcuts app, from Spotlight, and from the Action Button through a shortcut. An AppShortcutsProvider makes some intents available immediately after install, with spoken phrases. For a capture app this is the fastest door there is: a note saved with one press, without the app coming to the front. With openAppWhenRun false the intent may run outside the app's process, so it cannot touch the app's own actors or container. Its write belongs in the App Group drop box, the same one the share extension uses, which the sample passes in as the box closure.

Registered Everywhere, Running Nowhere

The travel journal's two intents registered perfectly: Spotlight showed one as the top hit, the Shortcuts library listed both, and the built bundle's metadata carried every intent. Running either one said only "Unable to run App Shortcut" or "Couldn't find shortcut". Two causes were stacked, and the order of checking them matters. The first was real and the app's own: a shortcut run from Spotlight or the Shortcuts library cannot prompt for a required parameter, so the system launched the app, found a value it could not supply, and gave up with that message. The documented shape for free-text capture is an optional parameter and needsValueError inside perform(). The second was the Simulator, whose App Intents execution simply did not run them. A trivial intent with no parameters that only returns a result tells the two apart in one tap: if even that fails, stop rebuilding against the Simulator. Verify registration there and in the bundle's metadata, and verify execution on a device.

That experience shaped the code. The intent's real work lives in a plain type the unit suite calls, and perform() is parameter resolution plus one call, because an intent is instantiated by the system in a process no test controls.

Three Rules About the Words

Every App Shortcut phrase must contain \(.applicationName); a phrase without it would belong to no app, and the build halts with the file and line, so that rule needs no test of its own. Intent metadata may not name the device: a description saying "this iPhone" is refused by App Store Connect with ITMS-90626, after a successful upload, which is the silent one and gets the source-reading test from the TestFlight track. And IntentDescription and LocalizedStringResource take a literal: splitting a long sentence with + produces a plain String and the unhelpful error "no exact matches in call to initializer". Keep the sentence on one line, however long.

Code

An intent with an optional parameter, work in a testable type, and phrases that name the app·swift
import AppIntents
import Foundation

/// The work lives in a plain type the unit suite can call. perform() is resolution plus one call,
/// because an intent runs in whatever process the system chooses, where no test can reach it.
struct CaptureFiler: Sendable {
    let box: @Sendable (_ text: String, _ origin: String) throws -> String

    func file(_ text: String) throws -> String {
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { throw CaptureError.empty }
        return try box(trimmed, "intent")
    }
}

enum CaptureError: Error, CustomLocalizedStringResourceConvertible {
    case empty
    var localizedStringResource: LocalizedStringResource { "There is nothing to save." }
}

struct CaptureNoteIntent: AppIntent {
    static let title: LocalizedStringResource = "Capture a Note"
    // A literal on one line: "a" + "b" is a plain String and fails with "no exact matches".
    static let description = IntentDescription("Saves a note to Spark straight away, with or without a network.")
    static let openAppWhenRun = false   // may run out of process: write to the App Group drop box only

    /// Optional on purpose. A required parameter cannot be prompted when the shortcut runs from
    /// Spotlight or the Shortcuts library, and the run fails with "Unable to run App Shortcut".
    @Parameter(title: "Note") var text: String?

    func perform() async throws -> some IntentResult & ProvidesDialog {
        guard let text, !text.isEmpty else {
            throw $text.needsValueError("What should Spark save?")
        }
        let filer = CaptureFiler { text, origin in "cap-\(UUID().uuidString.prefix(8))" }   // app: SharedDropBox
        _ = try filer.file(text)
        return .result(dialog: "Saved.")
    }
}

struct SparkShortcuts: AppShortcutsProvider {
    static var appShortcuts: [AppShortcut] {
        AppShortcut(
            intent: CaptureNoteIntent(),
            phrases: [
                "Capture a note in \(.applicationName)",   // every phrase names the app, or the build halts
                "\(.applicationName) note",
            ],
            shortTitle: "Capture Note",
            systemImageName: "square.and.pencil"
        )
    }
}
Check registration in the built bundle; prove execution on a device·bash
# Registration is visible in the built bundle, even where execution is not.
app=build/SparkMobile-42.xcarchive/Products/Applications/SparkMobile.app
python3 -c "
import json, sys
data = json.load(open(sys.argv[1]))
print('actions:', sorted(data['actions']))
print('app shortcuts:', len(data.get('autoShortcuts', [])))
" "$app/Metadata.appintents/extract.actionsdata"
#   actions: ['CaptureNoteIntent']

# Execution is proven on a device. To tell a Simulator that cannot run App Shortcuts from a
# bug of your own, add a trivial intent (no parameters, openAppWhenRun = true, returns .result())
# and run it first: if even that fails, stop rebuilding against the Simulator.

External links

Exercise

Add CaptureNoteIntent and SparkShortcuts to SparkMobile, build, and read the intent identifiers from the bundle's Metadata.appintents/extract.actionsdata. Then remove \(.applicationName) from one phrase, build again and copy the exact error and where it points. Restore it. Finally, write a unit test for CaptureFiler that proves an empty note is refused and a real note reaches the box closure, without any App Intents code running.
Hint
The phrase error comes from the App Intents metadata processor during the build, not from the Swift compiler, so read the build log rather than looking for a red line in the editor. The filer's box closure is where the real app passes its drop box, and where a test passes a closure that records what it received.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.