跳到正文
DEV Community· Dan·· 4 小時前AI 評分62

expo-app-intents 教學:將 Apple App Intents 接入 Expo App 的 JavaScript

Wiring Apple's App Intents into your Expo app's JavaScript with expo-app-intents

AI 導讀

expo-app-intents 將 Apple App Intents 接入 Expo App,讓用戶可透過 Siri 和 Shortcuts 執行操作,並在 Spotlight 搜尋應用程式內容。

正文

You've built a restaurant app, and a customer tells Siri "order the tiramisu" expecting it to just work. It doesn't, because your app has no way to tell iOS what actions it supports or what data lives inside it. That's the gap expo-app-intents closes.

The package connects your Expo app to Apple's App Intents framework, so people can run your app's actions through Siri and Shortcuts, find its content in Spotlight, and give Apple Intelligence context about what's on screen. A restaurant app can offer dishes as shortcut parameters. A mail app can expose draft actions and tell the system which draft is currently open.

The package is in alpha. We recommend building with Xcode 27 and testing on iOS 27, though it supports iOS 16.4 and later. Some of the walkthrough below needs the newer Xcode and iOS versions. Here's how it works.

The problem: Apple needs Swift before your JS ever runs

Bringing App Intents to an Expo app runs into two walls. First, Apple discovers intent declarations when Xcode builds your app, which means they have to exist as Swift types before the app ever launches, not something you can register dynamically from JavaScript at runtime. Second, an intent can fire before your JavaScript bundle has even started, so whatever it triggers needs to survive until your JS code is ready to handle it.

expo-app-intents solves both problems: it connects Swift declarations to JavaScript handlers, and it stores pending invocations natively until your app can process them. The initializer ships with templates that show how to wire up your own actions and data on top of that foundation.

What you can build with it

With expo-app-intents, your app can:

  • Expose actions through Siri and Shortcuts, including phrases that prompt for missing parameters
  • Let people pick current app data (a dish from a menu, for example) as an action parameter
  • Adopt Apple's app schemas for actions like creating mail drafts, so the system understands what the action means, which enables discovery by Apple Intelligence and Siri
  • Make app content searchable in Spotlight and tie it to the view currently showing it, giving on-screen intelligence context

You decide which actions and records to expose. Templates cover a counter, restaurant ordering, and mail drafts, or you can start from a minimal setup module.

How the pieces fit together

During a build, Xcode's App Intents metadata processor pulls information out of concrete Swift types conforming to protocols like AppIntent, AppEntity, EntityQuery, and AppShortcutsProvider. JavaScript can't create these declarations at runtime, so you still write the native Swift types by hand.

To make that less painful, expo-app-intents uses Expo inline modules. The initializer sets up an app-intents/ directory where Swift files compile straight into your app target, no manual Xcode project editing required. From those files you import ExpoAppIntents to dispatch invocations to JS and read entity catalogs that JavaScript populates.

This split matters: the declarations Apple needs at build time are separate from the data and logic that can change at runtime. Edit a Swift declaration or shortcut phrase and you need a new native build. Update a JS handler or publish new catalog values and you don't.

Installing it

Run the install command for expo-app-intents. The init command below writes the config for you, but if you're setting it up by hand, add the config plugin and enable inline modules for app-intents in your app.json:

{
  "expo": {
    "plugins": ["expo-app-intents"],
    "experiments": {
      "inlineModules": {
        "watchedDirectories": ["app-intents"]
      }
    }
  }
}

If you work with coding agents, @expo/agent-cli can install a bundled expo-app-intents skill that lets an agent wire up App Intents support even without native development experience.

Walkthrough: a Siri-triggered counter

This part needs a development build, since the package isn't available in Expo Go.

Run the initializer and pick the counter template. It creates app-intents/, adds the config plugin, and sets up inline modules. Inside, IncreaseCounterIntent.swift declares the action. Here's the template with its dialog changed from "Counter increased." to "Opening the counter.":

import AppIntents
internal import ExpoAppIntents

struct IncreaseCounterIntent: AppIntent {
  static let title: LocalizedStringResource = "Increase Counter"
  static let openAppWhenRun: Bool = true

  @MainActor
  func perform() async throws -> some IntentResult & ProvidesDialog {
    await AppIntentDispatcher.shared.dispatch(name: "increaseCounter")
    return .result(dialog: "Opening the counter.")
  }
}

openAppWhenRun tells the system to open your app. dispatch() queues the invocation without waiting on JavaScript, which is why the dialog describes opening the app rather than a finished state change.

The generated AppShortcuts.swift provides the phrase that triggers it:

import AppIntents

struct AppShortcuts: AppShortcutsProvider {
  static var appShortcuts: [AppShortcut] {
    AppShortcut(
      intent: IncreaseCounterIntent(),
      phrases: [
        "Increase the counter in \(.applicationName)"
      ],
      shortTitle: "Increase Counter",
      systemImageName: "plus.circle"
    )
  }
}

\(.applicationName) inserts your app's name, and every shortcut phrase needs it. AppIntentsSetup.swift wires this provider to the package so it can request shortcut parameter refreshes.

Render this on your initial screen, mounted once so you don't double-handle invocations:

import { useRef, useState } from "react";
import { Text, View } from "react-native";
import { useAppIntents, removePendingInvocationAsync } from "expo-app-intents";

export function CounterIntentHandler() {
  const [count, setCount] = useState(0);
  const handledIds = useRef(new Set<string>());

  useAppIntents(async (pendingIntents) => {
    for (const invocation of pendingIntents) {
      if (invocation.name !== "increaseCounter") {
        continue;
      }

      if (!handledIds.current.has(invocation.id)) {
        setCount((value) => value + 1);
        handledIds.current.add(invocation.id);
      }

      await removePendingInvocationAsync(invocation.id);
    }
  });

  return (
    <View>
      <Text>Counter: {count}</Text>
    </View>
  );
}

useAppIntents() delivers any pending invocation on mount, then handles live ones while the app runs. Removing a handled invocation from state acknowledges it to the native queue, and the ID check stops a second delivery from double-incrementing while this component is mounted.

Because this example stores the count in React state, it resets on remount, setCount() doesn't persist anything. For changes that need to survive a restart, persist both the result and the handled invocation ID atomically in your own storage before acknowledging it, so a repeated delivery recognizes work that already happened. The queue only holds the newest 100 invocations, so don't let it pile up.

Build and launch, then open Shortcuts, find your app, and run Increase Counter. Or just say "Increase the counter in [your app name]" to Siri. Keep the handler mounted while you test repeated invocations.

Giving an intent real app data

The counter takes no parameters. The restaurant template adds a required DishEntity, so people can pick a dish by name. Clear out the counter's app-intents/ directory and initialize the restaurant example instead. This regenerates AppShortcuts.swift and AppIntentsSetup.swift with restaurant-specific phrases and handlers.

Swap in RestaurantExample, which publishes a small menu on mount and shows whichever dish an orderFood invocation selects:

import { useEffect, useState } from "react";
import { Text, View } from "react-native";
import {
  removePendingInvocationAsync,
  setEntityCatalogAsync,
  useAppIntents,
} from "expo-app-intents";

const menu = [
  {
    id: "margherita-pizza",
    title: "Margherita Pizza",
    synonyms: ["margherita", "pizza"],
  },
  { id: "tiramisu", title: "Tiramisu", synonyms: ["dessert"] },
];

export function RestaurantExample() {
  const [status, setStatus] = useState("Publishing menu...");
  const [selectedDish, setSelectedDish] = useState<string | null>(null);

  useEffect(() => {
    setEntityCatalogAsync("dish", menu)
      .then(() => setStatus("Menu ready."))
      .catch((error) => setStatus(`Could not publish menu: ${String(error)}`));
  }, []);

  useAppIntents(async (pendingIntents) => {
    for (const invocation of pendingIntents) {
      if (invocation.name !== "orderFood") continue;

      const dish = menu.find((item) => item.id === invocation.params.dishId);
      if (!dish) throw new Error("The requested dish is not on the menu.");

      setSelectedDish(dish.title);
      await removePendingInvocationAsync(invocation.id);
    }
  });

  return (
    <View>
      <Text>{status}</Text>
      <Text>Selected dish: {selectedDish ?? "None"}</Text>
    </View>
  );
}

This confirms the selected dish in the UI without submitting an order, and a repeated delivery just reselects the same dish. A real order handler needs the same persistent deduplication described above.

The generated DishQuery reads the published catalog and turns records into DishEntity values:

func entities(for identifiers: [String]) async throws -> [DishEntity] {
  return try await AppIntentEntityStore.shared.entities(ofKind: "dish", matching: identifiers)
    .map(DishEntity.init(record:))
}

The template also handles suggestions and string matching against dish IDs, names, and synonyms. Use the same "dish" kind on both sides, with stable non-empty IDs and titles.

Catalogs persist natively, so a query can read the last published menu even before JS starts. Publishing replaces the whole catalog and triggers a shortcut parameter refresh when values change, so publish after data loads and on every update, never with an empty loading state (it'll wipe out the previous menu).

OrderFoodIntent.perform() dispatches the selected dish:

await AppIntentDispatcher.shared.dispatch(
  name: "orderFood",
  params: [
    "dishId": .string(dish.id),
    "dishName": .string(dish.name),
  ]
)

Rebuild with npx expo run:ios --device, wait for "Menu ready," then try "Order Tiramisu in [your app name]" or "Place an order in [your app name]" and pick a dish when Siri asks.

Adopting an Apple schema

App schema domains describe actions and content types that apps share, like creating a mail draft. Conforming to one gives the system predefined properties and behavior to work with, and Apple's primary domains are what Apple Intelligence and Siri use for discovery.

The mail template demonstrates the primary mail domain, and its base intents need iOS 18 or later. Clear app-intents/ and initialize the complete mail example, which also includes the Spotlight and on-screen integration covered next (that part needs Xcode 27 and iOS 18.4, and its open-draft intent needs iOS 27).

This excerpt from the generated CreateDraftIntent.swift shows the schema annotation:

@available(iOS 18.0, *)
@AppIntent(schema: .mail.createDraft)
struct CreateDraftIntent {
  static let openAppWhenRun: Bool = true

  var body: AttributedString?
  var to: [IntentPerson]
  var subject: String?
  var cc: [IntentPerson]
  var bcc: [IntentPerson]
  var account: MailAccountEntity?
  var attachments: [IntentFile]

  // perform() omitted from this excerpt.
}

The schema dictates the required properties. The full template builds a MailDraftEntity in perform(), dispatches createMailDraft to JS, and returns that entity, JS still has to actually save the draft. The sample payload includes the attachment count, not the files.

Notice there's no shortcut phrase needed here. Conforming to the mail schema tells the system which standard actions your app supports and what parameters they take, and Xcode bakes that into the app's metadata at build time. Siri and Apple Intelligence can discover the action without an AppShortcut entry, and it's also available in Shortcuts.

Swap in MailExample, which reads saved drafts from the native catalog on mount and republishes the list after every create or delete:

import { useState } from "react";
import { Button, ScrollView, Text } from "react-native";
import {
  AppEntityView,
  getEntityCatalogAsync,
  removePendingInvocationAsync,
  setEntityCatalogAsync,
  useAppIntents,
  type AppIntentEntity,
} from "expo-app-intents";

export function MailExample() {
  const [drafts, setDrafts] = useState<AppIntentEntity[]>([]);
  const [selectedId, setSelectedId] = useState<string | null>(null);

  useAppIntents(async (pendingIntents) => {
    let current = await getEntityCatalogAsync("mailDraft");
    setDrafts(current);

    for (const { id, name, params } of pendingIntents) {
      switch (name) {
        case "createMailDraft": {
          if (typeof params.id !== "string" || !params.id.trim()) {
            throw new Error("Missing draft ID.");
          }
          const draft: AppIntentEntity = {
            id: params.id,
            title:
              typeof params.subject === "string"
                ? params.subject.trim() || "No subject"
                : "No subject",
            subtitle: typeof params.body === "string" ? params.body : "",
          };
          current = [...current.filter((item) => item.id !== draft.id), draft];
          setSelectedId(draft.id);
          break;
        }
        case "deleteMailDrafts": {
          const ids = params.ids;
          if (
            !Array.isArray(ids) ||
            !ids.every((value) => typeof value === "string")
          ) {
            throw new Error("Invalid draft IDs.");
          }
          current = current.filter((draft) => !ids.includes(draft.id));
          break;
        }
        case "openMailDraft": {
          if (typeof params.id !== "string")
            throw new Error("Missing draft ID.");
          setSelectedId(params.id);
          await removePendingInvocationAsync(id);
          continue;
        }
        default:
          continue;
      }

      await setEntityCatalogAsync("mailDraft", current);
      setDrafts(current);
      await removePendingInvocationAsync(id);
    }
  });

  const selectedDraft = drafts.find((draft) => draft.id === selectedId);

  return (
    <ScrollView>
      <Text>Drafts: {drafts.length}</Text>
      {drafts.map((draft) => (
        <Button
          key={draft.id}
          title={draft.title}
          onPress={() => setSelectedId(draft.id)}
        />
      ))}
      {selectedDraft && (
        <AppEntityView entity="mailDraft" entityId={selectedDraft.id}>
          <Text>{selectedDraft.title}</Text>
          <Text>{selectedDraft.subtitle}</Text>
        </AppEntityView>
      )}
    </ScrollView>
  );
}

It waits for the catalog read before changing anything, so the initial empty React state can't wipe out saved records, and it publishes before acknowledging create/delete invocations. Creating a draft with an existing ID replaces it, deleting a missing ID is a no-op, both are safe against repeated delivery.

For brevity this demo only keeps IDs, subjects, and bodies in the catalog. A real mail app should store full drafts elsewhere and publish just what intents need. The openMailDraft branch is the JS-side behavior the generated iOS 27 open intent relies on.

Rebuild and try: "Create an email draft in [your app name] with the subject Release notes and the body The release notes are ready for review," then "Delete the email draft titled Release notes in [your app name]." These are natural-language requests, not registered shortcut phrases. The delete intent requires device authentication and confirmation and dispatches deleteMailDrafts without opening the app, so if JS isn't running, open the app afterward to let your handler apply it.

Making content findable in Spotlight

Still on the mail example: the --visual-intelligence init option makes MailDraftEntity indexable in Spotlight and adds support for transferring and opening drafts. It generates a call to AppEntityIdentifierRegistry.shared.registerIndexed("mailDraft", as: MailDraftEntity.self) for you, no manual registration needed.

That registration connects the mailDraft catalog to Spotlight indexing and makes the kind available for on-screen entity identifiers. A custom indexed entity needs IndexedEntity and AppIntentEntityRecordConvertible conformance, both generated for you here.

MailExample already publishes the drafts it displays under mailDraft. The create intent's ID stays consistent across the catalog, the list, and the selected draft's AppEntityView, and selecting a draft ties the visible subject and body to the native entity.

For an @expo/ui SwiftUI view, import { appEntityIdentifier } from "expo-app-intents" and apply appEntityIdentifier('mailDraft', draft.id) through its modifiers prop to tell the system which entity a view represents. Use register() instead of registerIndexed() if you want on-screen context without Spotlight indexing, and publish a record with hideInSpotlight: true to keep it resolvable by intents but out of search.

To test it, create a draft via Siri titled "Spotlight demo," wait for it to show up, then search "Spotlight demo" in Spotlight once indexing finishes. On iOS 27, opening the result dispatches openMailDraft, and the handler selects the matching record and shows its body.

Limitations

  • expo-app-intents is alpha, and the API may still change
  • Not available in Expo Go, you need a development build
  • Intent declarations and shortcut phrases live in Swift, so changing them means a new native build
  • The pending invocation queue only holds the newest 100 invocations
  • On-screen association needs Xcode 27+ and iOS 18.4+, and the open-draft intent specifically needs iOS 27

Where to start

  • Pick one of the init templates (counter, restaurant, or mail) that matches what you're building, or start minimal if you just need the setup module
  • Read the expo-app-intents docs for the full API and platform requirements
  • Check the source and templates on GitHub, and this example app for more advanced usage

If you hit issues using the templates in your own app, file them on GitHub.

This post is based on content from the Expo blog. Follow @expo for more React Native content.

來源:DEV Community · dev.to