mcpField
A
axiom-app-intents-ref

axiom-app-intents-ref

github.com/charleswiltgen開発1.2k184 回インストール今日更新
リポジトリ

axiom-app-intents-ref について

AppleのApp Intentsフレームワークを使い、アプリの操作やコンテンツをシステム機能に公開するためのSwift開発者向けガイドです。インテントやエンティティの設計から、Siri、ショートカット、ウィジェット、Visual Intelligenceとの連携まで扱います。

主な機能

  1. 01アクション、エンティティ、列挙型の定義
  2. 02パラメータとエンティティクエリの設定
  3. 03実行方法と認証の制御
  4. 04確認と選択のフロー追加
  5. 05インタラクティブなスニペットの返却
  6. 06Appleのシステム機能との連携

活用シーン

  1. 01アプリの操作をSiriやショートカットに公開
  2. 02Spotlightでアプリのコンテンツを検索可能にする
  3. 03Visual Intelligenceの結果クエリを実装

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-app-intents-ref

インストール後、Claude Code で/axiom-app-intents-refを入力して読み込まれたか確認します。

詳細

App Intents の統合

概要

Siri、Apple Intelligence、ショートカット、Spotlight、その他のシステム機能にアプリの機能を公開するための App Intents フレームワークについて、包括的に説明します。従来の SiriKit カスタムインテントを、最新の Swift ファーストな API に置き換えます。

基本原則 App Intents により、Apple のエコシステム全体でアプリのアクションを見つけられるようになります。適切に設計されたインテントは、Siri との会話、ショートカットの自動化、Spotlight 検索で自然に使えます。

このスキルを使用する場面

  • アプリの機能を Siri や Apple Intelligence に公開する
  • アプリのアクションをショートカットアプリで利用できるようにする
  • アプリのコンテンツを Spotlight で検索できるようにする
  • 集中モードのフィルタ、ウィジェット、ライブアクティビティと連携する
  • アクションボタン(Apple Watch Ultra)に対応する
  • インテントの解決やパラメーターの検証に失敗する問題をデバッグする
  • ショートカットアプリでインテントをテストする
  • アプリのコンテンツに対するエンティティクエリを実装する

関連スキル

  • app-shortcuts-ref — ユーザーによる設定なしで Siri/Spotlight からすぐに利用できる App Shortcuts
  • core-spotlight-ref — コンテンツのインデックス作成のための Core Spotlight と NSUserActivity の統合
  • app-discoverability — あらゆる API を活用し、システム全体でアプリを表示するための戦略ガイド

対応するシステム機能

App Intents は次の機能と統合されます。

  • Siri — 音声コマンドと Apple Intelligence
  • ショートカット — 自動化ワークフロー
  • App Shortcuts — 事前設定済みのアクションをすぐに利用可能(app-shortcuts-ref を参照)
  • Spotlight — 検索による発見
  • 集中モードのフィルタ — コンテキストに応じたフィルタリング
  • アクションボタン — クイックアクション(Apple Watch Ultra)
  • コントロールセンター — カスタムコントロール
  • WidgetKit — インタラクティブなウィジェット
  • ライブアクティビティ — Dynamic Island の更新
  • Visual Intelligence — 画像を使ったインタラクション

Visual Intelligence との統合

IntentValueQuery

ユーザーが Visual Intelligence のカメラでオブジェクトを囲むと、アプリから一致する結果を表示できるようにします。

@UnionValue
enum VisualSearchResult {
    case landmark(LandmarkEntity)
    case collection(CollectionEntity)
}

struct LandmarkIntentValueQuery: IntentValueQuery {
    func values(for input: SemanticContentDescriptor) async throws -> [VisualSearchResult] {
        // Match visual input to app entities
    }
}

// Each entity type needs an OpenIntent
struct OpenLandmarkIntent: OpenIntent { /* ... */ }
struct OpenCollectionIntent: OpenIntent { /* ... */ }
画面上のエンティティ

アプリのエンティティを画面に表示されているコンテンツに関連付け、ユーザーが Siri や ChatGPT に画面の内容について質問できるようにします。

struct LandmarkDetailView: View {
    let landmark: LandmarkEntity

    var body: some View {
        Group { /* View content */ }
        .userActivity("com.landmarks.ViewingLandmark") { activity in
            activity.title = "Viewing \(landmark.name)"
            activity.appEntityIdentifier = EntityIdentifier(for: landmark)
        }
    }
}

基本概念

3 つの構成要素

1. AppIntent — パラメーターを持つ実行可能なアクション

struct OrderSoupIntent: AppIntent {
    static var title: LocalizedStringResource = "Order Soup"
    static var description: IntentDescription = "Orders soup from the restaurant"

    @Parameter(title: "Soup")
    var soup: SoupEntity

    @Parameter(title: "Quantity")
    var quantity: Int?

    func perform() async throws -> some IntentResult {
        guard let quantity = quantity, quantity < 10 else {
            throw $quantity.needsValue("Please specify how many soups")
        }

        try await OrderService.shared.order(soup: soup, quantity: quantity)
        return .result()
    }
}

2. AppEntity — ユーザーが操作するオブジェクト

struct SoupEntity: AppEntity {
    var id: String
    var name: String
    var price: Decimal

    static var typeDisplayRepresentation: TypeDisplayRepresentation = "Soup"

    var displayRepresentation: DisplayRepresentation {
        DisplayRepresentation(title: "\(name)", subtitle: "$\(price)")
    }

    static var defaultQuery = SoupQuery()
}

3. AppEnum — パラメーターに使用する列挙型

enum SoupSize: String, AppEnum {
    case small
    case medium
    case large

    static var typeDisplayRepresentation: TypeDisplayRepresentation = "Size"
    static var caseDisplayRepresentations: [SoupSize: DisplayRepresentation] = [
        .small: "Small (8 oz)",
        .medium: "Medium (12 oz)",
        .large: "Large (16 oz)"
    ]
}

AppIntent:アクションの定義

必須のプロパティ
struct SendMessageIntent: AppIntent {
    // REQUIRED: Short verb-noun phrase
    static var title: LocalizedStringResource = "Send Message"

    // REQUIRED: Purpose explanation
    static var description: IntentDescription = "Sends a message to a contact"

    // OPTIONAL: Discovery in Shortcuts/Spotlight
    static var isDiscoverable: Bool = true

    // OPTIONAL: Launch app when run
    static var openAppWhenRun: Bool = false

    // OPTIONAL: Authentication requirement
    static var authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication
}
パラメーターの宣言
struct BookAppointmentIntent: AppIntent {
    // Required parameter (non-optional)
    @Parameter(title: "Service")
    var service: ServiceEntity

    // Optional parameter
    @Parameter(title: "Preferred Date")
    var preferredDate: Date?

    // Parameter with requestValueDialog for disambiguation
    @Parameter(title: "Location",
               requestValueDialog: "Which location would you like to visit?")
    var location: LocationEntity

    // Parameter with default value
    @Parameter(title: "Duration")
    var duration: Int = 60
}
パラメーターの概要(Siri での言い回し)
struct OrderIntent: AppIntent {
    @Parameter(title: "Item")
    var item: MenuItem

    @Parameter(title: "Quantity")
    var quantity: Int

    static var parameterSummary: some ParameterSummary {
        Summary("Order \(\.$quantity) \(\.$item)") {
            \.$quantity
            \.$item
        }
    }
}
// Siri: "Order 2 lattes"
perform() メソッド
func perform() async throws -> some IntentResult {
    // 1. Validate parameters
    guard quantity > 0 && quantity < 100 else {
        throw ValidationError.invalidQuantity
    }

    // 2. Execute action
    let order = try await orderService.placeOrder(
        item: item,
        quantity: quantity
    )

    // 3. Donate for learning (optional)
    await donation()

    // 4. Return result
    return .result(
        value: order,
        dialog: "Your order for \(quantity) \(item.name) has been placed"
    )
}
エラー処理
enum OrderError: Error, CustomLocalizedStringResourceConvertible {
    case outOfStock(itemName: String)
    case paymentFailed
    case networkError

    var localizedStringResource: LocalizedStringResource {
        switch self {
        case .outOfStock(let name):
            return "Sorry, \(name) is out of stock"
        case .paymentFailed:
            return "Payment failed. Please check your payment method"
        case .networkError:
            return "Network error. Please try again"
        }
    }
}

func perform() async throws -> some IntentResult {
    if !item.isInStock {
        throw OrderError.outOfStock(itemName: item.name)
    }
    // ...
}

AppEntity:アプリコンテンツの表現

エンティティの定義
struct BookEntity: AppEntity {
    // REQUIRED: Unique, persistent identifier
    var id: UUID

    // App data properties
    var title: String
    var author: String
    var coverImageURL: URL?

    // REQUIRED: Type display name
    static var typeDisplayRepresentation: TypeDisplayRepresentation = "Book"

    // REQUIRED: Instance display
    var displayRepresentation: DisplayRepresentation {
        DisplayRepresentation(
            title: "\(title)",
            subtitle: "by \(author)",
            image: coverImageURL.map { .init(url: $0) }
        )
    }

    // REQUIRED: Query for resolution
    static var defaultQuery = BookQuery()
}
プロパティの公開
struct TaskEntity: AppEntity {
    var id: UUID

    @Property(title: "Title")
    var title: String

    @Property(title: "Due Date")
    var dueDate: Date?

    @Property(title: "Priority")
    var priority: TaskPriority

    @Property(title: "Completed")
    var isCompleted: Bool

    // Properties exposed to system for filtering/sorting
}
計算プロパティと遅延プロパティ
@ComputedProperty

信頼できる情報源から直接読み取る計算プロパティ(保存値はありません)。

struct SettingsEntity: UniqueAppEntity {
    @ComputedProperty
    var defaultPlace: PlaceDescriptor {
        UserDefaults.standard.defaultPlace
    }

    init() { }
}
@DeferredProperty

計算コストが高く、明示的に要求されたときにのみ取得するプロパティです。

struct LandmarkEntity: IndexedEntity {
    @DeferredProperty
    var crowdStatus: Int {
        get async throws {
            await modelData.getCrowdStatus(self)
        }
    }
}
エンティティクエリ
struct BookQuery: EntityQuery {
    func entities(for identifiers: [UUID]) async throws -> [BookEntity] {
        // Fetch entities by IDs
        return try await BookService.shared.fetchBooks(ids: identifiers)
    }

    func suggestedEntities() async throws -> [BookEntity] {
        // Provide suggestions (recent, favorites, etc.)
        return try await BookService.shared.recentBooks(limit: 10)
    }
}

// Optional: Enable string-based search
extension BookQuery: EntityStringQuery {
    func entities(matching string: String) async throws -> [BookEntity] {
        return try await BookService.shared.searchBooks(query: string)
    }
}
エンティティとモデルの分離
❌ 禁止:コアデータモデルを変更する
// DON'T make your model conform to AppEntity
struct Book: AppEntity { // Bad - couples model to intents
    var id: UUID
    var title: String
    // ...
}
✅ 推奨:専用のエンティティを作成する
// Your core model
struct Book {
    var id: UUID
    var title: String
    var isbn: String
    var pages: Int
    // ... lots of internal properties
}

// Separate entity for intents
struct BookEntity: AppEntity {
    var id: UUID
    var title: String
    var author: String

    // Convert from model
    init(from book: Book) {
        self.id = book.id
        self.title = book.title
        self.author = book.author.name
    }
}

認証とセキュリティ

認証ポリシー
struct ViewAccountIntent: AppIntent {
    // No authentication required
    static var authenticationPolicy: IntentAuthenticationPolicy = .alwaysAllowed
}

struct TransferMoneyIntent: AppIntent {
    // Requires user to be logged in
    static var authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication
}

struct UnlockVaultIntent: AppIntent {
    // Requires device unlock (Face ID/Touch ID/passcode)
    static var authenticationPolicy: IntentAuthenticationPolicy = .requiresLocalDeviceAuthentication
}

バックグラウンド実行とフォアグラウンド実行

インテントのモード

ブール値の openAppWhenRun の代わりに supportedModes を使用して、実行コンテキストを細かく制御します。

struct GetCrowdStatusIntent: AppIntent {
    static let supportedModes: IntentModes = [.background, .foreground(.dynamic)]

    func perform() async throws -> some ReturnsValue<Int> & ProvidesDialog {
        guard await modelData.isOpen(landmark) else {
            return .result(value: 0, dialog: "The landmark is currently closed.")
        }

        if systemContext.currentMode.canContinueInForeground {
            do {
                try await continueInForeground(alwaysConfirm: false)
                await navigator.navigateToCrowdStatus(landmark)
            } catch {
                // Opening app was denied
            }
        }

        let status = await modelData.getCrowdStatus(landmark)
        return .result(value: status, dialog: "Current crowd level: \(status)")
    }
}
利用可能なモード
モード動作
.background完全にバックグラウンドで実行します
.foreground(.immediate)perform() の実行前にアプリをフォアグラウンドにします
.foreground(.dynamic)実行中にフォアグラウンドへの切り替えを要求できます
.foreground(.deferred)最初はバックグラウンドで実行し、完了前にフォアグラウンドにします
一般的な組み合わせ
組み合わせ使用する場合
[.background, .foreground]フォアグラウンドがデフォルト、バックグラウンドはフォールバック
[.background, .foreground(.dynamic)]バックグラウンドがデフォルト、フォアグラウンドを要求可能
[.background, .foreground(.deferred)]最初はバックグラウンド、要求時にはフォアグラウンドを保証
フォアグラウンドでの継続

.foreground(.dynamic) を使用する場合、実行時にフォアグラウンドへの移行を要求します:

// Normal transition
try await continueInForeground(alwaysConfirm: false)

// Transition after an error
throw needsToContinueInForegroundError(
    IntentDialog("Need to open app to complete this action"),
    alwaysConfirm: true
)
バックグラウンド実行(レガシー)
struct QuickToggleIntent: AppIntent {
    static var openAppWhenRun: Bool = false // Runs in background

    func perform() async throws -> some IntentResult {
        // Executes without opening app
        await SettingsService.shared.toggle(setting: .darkMode)
        return .result()
    }
}
フォアグラウンド継続(レガシー)
struct EditDocumentIntent: AppIntent {
    @Parameter(title: "Document")
    var document: DocumentEntity

    func perform() async throws -> some IntentResult {
        // Open app to continue in UI
        return .result(opensIntent: OpenDocumentIntent(document: document))
    }
}

struct OpenDocumentIntent: AppIntent {
    static var openAppWhenRun: Bool = true

    @Parameter(title: "Document")
    var document: DocumentEntity

    func perform() async throws -> some IntentResult {
        // App is now foreground, safe to update UI
        await MainActor.run {
            DocumentCoordinator.shared.open(document: document)
        }
        return .result()
    }
}

確認ダイアログ

確認の要求
struct DeleteTaskIntent: AppIntent {
    @Parameter(title: "Task")
    var task: TaskEntity

    func perform() async throws -> some IntentResult {
        // Request confirmation before destructive action
        try await requestConfirmation(
            result: .result(dialog: "Are you sure you want to delete '\(task.title)'?"),
            confirmationActionName: .init(stringLiteral: "Delete")
        )

        // User confirmed, proceed
        try await TaskService.shared.delete(task: task)
        return .result(dialog: "Task deleted")
    }
}

複数選択 API

構造化されたオプションでユーザー入力を要求します:

let options = [
    IntentChoiceOption(title: "Option 1", subtitle: "Description 1"),
    IntentChoiceOption(title: "Option 2", subtitle: "Description 2"),
    IntentChoiceOption.cancel(title: "Not now")
]

let choice = try await requestChoice(
    between: options,
    dialog: IntentDialog("Please select an option")
)

switch choice.id {
case options[0].id: // Option 1 selected
case options[1].id: // Option 2 selected
default: // Cancelled
}

インタラクティブスニペット

静的スニペット

インテントの結果を示す SwiftUI ビューを返します:

func perform() async throws -> some IntentResult {
    return .result(view: Text("Order placed!").font(.title))
}
SnippetIntent

フォローアップアクションボタン付きのインタラクティブスニペットを返します:

func perform() async throws -> some IntentResult {
    let landmark = await findNearestLandmark()

    return .result(
        value: landmark,
        opensIntent: OpenLandmarkIntent(landmark: landmark),
        snippetIntent: LandmarkSnippetIntent(landmark: landmark)
    )
}

struct LandmarkSnippetIntent: SnippetIntent {
    @Parameter var landmark: LandmarkEntity

    var snippet: some View {
        VStack {
            Text(landmark.name).font(.headline)
            Text(landmark.description).font(.body)

            HStack {
                Button("Add to Favorites") { /* action */ }
                Button("Search Tickets") { /* action */ }
            }
        }
        .padding()
    }
}

Swift Package のサポート

AppIntentsPackage

App Intents を Swift Package および静的ライブラリに含めます:

// In your framework or dynamic library
public struct LandmarksKitPackage: AppIntentsPackage { }

// In your app target
struct LandmarksPackage: AppIntentsPackage {
    static var includedPackages: [any AppIntentsPackage.Type] {
        [LandmarksKitPackage.self]
    }
}

これにより、パッケージ境界を越えたモジュール式のインテント定義が可能になります。アプリターゲットは includedPackages を介してすべてのパッケージを集約します。


Apple Intelligence:Use Model アクション

概要

ショートカットの Use Model アクション(iOS 18.1 以降)を使用すると、ユーザーは Apple Intelligence モデルを自動化ワークフローに組み込むことができます。アプリのエンティティを言語モデルに渡して、フィルタリング、変換、推論を行うことができます。

主な機能 内部的には、このアクションはエンティティの JSON 表現をモデルに渡すため、モデルが推論できるようにしたい情報はすべてエンティティ定義で公開するようにしてください。

3 つの出力タイプ
1. テキスト(AttributedString)
  • モデルはしばしばリッチテキスト(太字、斜体、リスト、表)で応答します
  • 書式を保持するには、テキストパラメータに AttributedString タイプを使用します
  • モデルからアプリへのロスレスな転送を可能にします
2. 辞書
  • 非構造化入力からの構造化データ抽出
  • PDF、メール、ドキュメントの解析に便利
  • 例:請求書から取引先、金額、日付を抽出する
3. アプリエンティティ(独自の型)
  • エンティティのリストをモデルに渡してフィルタリング/推論を行う
  • モデルはエンティティの JSON 表現を受け取ります
  • 例:「旅行に関連するカレンダーイベントをフィルタリングする」
エンティティをモデルに公開する

モデルは、以下を含むエンティティの JSON 表現を受け取ります:

1. 公開されたすべてのプロパティ(文字列に変換されます)

struct EventEntity: AppEntity {
    var id: UUID

    @Property(title: "Title")
    var title: String

    @Property(title: "Start Date")
    var startDate: Date

    @Property(title: "End Date")
    var endDate: Date

    @Property(title: "Notes")
    var notes: String?

    // All @Property values included in JSON for model
}

2. 型表示表現(エンティティが何を表すかのヒント)

static var typeDisplayRepresentation: TypeDisplayRepresentation = "Calendar Event"

3. 表示表現(タイトルとサブタイトル)

var displayRepresentation: DisplayRepresentation {
    DisplayRepresentation(
        title: "\(title)",
        subtitle: "\(startDate.formatted())"
    )
}
モデルに送信される JSON の例
{
  "type": "Calendar Event",
  "title": "Team Meeting",
  "subtitle": "Jan 15, 2025 at 2:00 PM",
  "properties": {
    "Title": "Team Meeting",
    "Start Date": "2025-01-15T14:00:00Z",
    "End Date": "2025-01-15T15:00:00Z",
    "Notes": "Discuss Q1 roadmap"
  }
}
AttributedString によるリッチテキストのサポート

なぜ重要か アプリがリッチテキストコンテンツをサポートしているなら、今こそ、アプリインテントのテキストパラメータに適切な箇所でアトリビューテッド文字列型を使用するようにしてください。

❌ しない:プレーンな String を使用する
struct CreateNoteIntent: AppIntent {
    @Parameter(title: "Content")
    var content: String // Loses formatting from model
}
✅ する:AttributedString を使用する
struct CreateNoteIntent: AppIntent {
    @Parameter(title: "Content")
    var content: AttributedString // Preserves Rich Text

    func perform() async throws -> some IntentResult {
        let note = Note(content: content) // Rich Text preserved
        try await NoteService.shared.save(note)
        return .result()
    }
}
WWDC からの実例

Bear アプリの Create Note は AttributedString を受け入れるため、ChatGPT からの日記テンプレートに以下を含めることができます:

  • 太字の見出し
  • 気分記録の表
  • 書式設定されたリスト
  • すべてロスレスに保持されます
自動型変換

Use Model の出力が別のアクションに接続されると、ランタイムが自動的に型を変換します:

例:If アクション用の Boolean
// User's shortcut:
// 1. Get notes created today
// 2. For each note:
//    - Use Model: "Is this note related to developing features for Shortcuts?"
//    - If [model output] = yes:
//      - Add to Shortcuts Projects folder

「はい、このノートは Shortcuts アプリの機能開発に関するもののようです」のような冗長なテキストを返す代わりに、If アクションに接続されると、モデルは自動的に Boolean(true/false)を返します。

利用可能な明示的な出力タイプ
  • テキスト(AttributedString)
  • 数値
  • Boolean
  • 辞書
  • 日付
  • アプリエンティティ
フォローアップ機能

次のアクションに渡す前に反復的な改善を可能にします:

// User runs shortcut:
// 1. Get recipe from Safari
// 2. Use Model: "Extract ingredients list"
//    - Follow Up: enabled
//    - User types: "Double the recipe"
//    - Model adjusts: 800g flour instead of 400g
// 3. Add to Grocery List in Things app
使用する場合
  • レシピの変更(分量の調整、材料の代替)
  • コンテンツの改善(トーン、長さ、スタイルの調整)
  • データ検証(保存前に抽出した値を確認する)

IndexedEntity:自動検索アクション

概要

IndexedEntityを使うと、Spotlightとの連携から検索アクションを自動生成でき、定型コードを大幅に削減できます。EntityQueryとEntityPropertyQueryを手動で実装する代わりに、IndexedEntityを採用すると、次の機能を利用できます。

  • ショートカットでの検索アクションの自動生成
  • プロパティに基づく絞り込み
  • 検索のサポート
  • 必要なコードを最小限に削減
基本的な実装
struct EventEntity: AppEntity, IndexedEntity {
    var id: UUID

    // 1. Properties with indexing keys
    @Property(title: "Title", indexingKey: \.eventTitle)
    var title: String

    @Property(title: "Start Date", indexingKey: \.startDate)
    var startDate: Date

    @Property(title: "End Date", indexingKey: \.endDate)
    var endDate: Date

    // 2. Custom key for properties without standard Spotlight attribute
    @Property(title: "Notes", customIndexingKey: "eventNotes")
    var notes: String?

    // Display representation automatically maps to Spotlight
    var displayRepresentation: DisplayRepresentation {
        DisplayRepresentation(
            title: "\(title)",
            subtitle: "\(startDate.formatted())"
            // title → kMDItemTitle
            // subtitle → kMDItemDescription
            // image → kMDItemContentType (if provided)
        )
    }

    static var typeDisplayRepresentation: TypeDisplayRepresentation = "Event"
}
インデックスキーのマッピング
標準のSpotlight属性キー
// Common Spotlight keys for events
@Property(title: "Title", indexingKey: \.eventTitle)
var title: String

@Property(title: "Start Date", indexingKey: \.startDate)
var startDate: Date

@Property(title: "Location", indexingKey: \.eventLocation)
var location: String?
標準以外の属性に対するカスタムキー
@Property(title: "Notes", customIndexingKey: "eventNotes")
var notes: String?

@Property(title: "Attendee Count", customIndexingKey: "attendeeCount")
var attendeeCount: Int
自動生成される検索アクション

IndexedEntityに準拠すると、ユーザーは次の検索アクションを自動的に利用できます。

ショートカットアプリの場合
Find Events where:
  - Title contains "Team"
  - Start Date is today
  - Location is "San Francisco"
IndexedEntityを使わない場合、次のものを手動で実装する必要があります
  • EnumerableEntityQueryプロトコル
  • EntityPropertyQueryプロトコル
  • 検索可能な各フィールドのプロパティフィルター
  • 検索/候補表示のロジック

IndexedEntityを使う場合 インデックスキーを追加するだけで完了です!

検索のサポート

EntityStringQueryを実装して、文字列ベースの検索を有効にします。

extension EventEntityQuery: EntityStringQuery {
    func entities(matching string: String) async throws -> [EventEntity] {
        return try await EventService.shared.search(query: string)
    }
}

または、IndexedEntityとSpotlightを利用して検索を自動化できます。

Spotlightの明示的なインデックス作成

カスタムの検索可能属性や手動でのインデックス管理が必要なエンティティの場合:

extension LandmarkEntity {
    var searchableAttributes: CSSearchableItemAttributeSet {
        let attributes = CSSearchableItemAttributeSet()
        attributes.title = name
        attributes.namedLocation = regionDescription
        attributes.keywords = activities
        attributes.latitude = NSNumber(value: coordinate.latitude)
        attributes.longitude = NSNumber(value: coordinate.longitude)
        attributes.supportsNavigation = true
        return attributes
    }
}

// Add entities to index
func indexLandmarks() async {
    let landmarks = await fetchLandmarks()
    try await CSSearchableIndex.default().indexAppEntities(landmarks, priority: .normal)
}

// Remove from index when deleted
func deleteLandmark(_ landmark: LandmarkEntity) async {
    await dataStore.delete(landmark)
    try await CSSearchableIndex.default().deleteAppEntities(
        identifiedBy: [landmark.id],
        ofType: LandmarkEntity.self
    )
}
例:旅行追跡アプリ

Appleのサンプルコード(App Intents Travel Tracking App)で、IndexedEntityの使用例を確認できます。

struct TripEntity: AppEntity, IndexedEntity {
    var id: UUID

    @Property(title: "Name", indexingKey: \.title)
    var name: String

    @Property(title: "Start Date", indexingKey: \.startDate)
    var startDate: Date

    @Property(title: "End Date", indexingKey: \.endDate)
    var endDate: Date

    @Property(title: "Destination", customIndexingKey: "destination")
    var destination: String

    // Auto-generated Find Trips action with filters for all properties
}

MacのSpotlight

概要

MacのSpotlight(macOS Sequoia以降)では、システム検索からアプリのインテントを直接実行できます。適切に設定すれば、ショートカットで動作するインテントはSpotlightでも自動的に動作します。

重要な原則 Spotlightでは、操作をすばやく実行できることが重要です。そのためには、インテントの実行に必要な情報をすべて、Spotlight内で直接入力できるようにする必要があります。

Spotlightに表示するための要件
1. パラメーターの概要にすべての必須パラメーターを含める

SpotlightのUIに表示されるパラメーターの概要には、デフォルト値のない必須パラメーターをすべて含める必要があります。

❌ Spotlightに表示されません
struct CreateEventIntent: AppIntent {
    static var title: LocalizedStringResource = "Create Event"

    @Parameter(title: "Title")
    var title: String

    @Parameter(title: "Start Date")
    var startDate: Date

    @Parameter(title: "End Date")
    var endDate: Date

    @Parameter(title: "Notes") // Required, no default
    var notes: String

    static var parameterSummary: some ParameterSummary {
        Summary("Create '\(\.$title)' from \(\.$startDate) to \(\.$endDate)")
        // Missing 'notes' parameter!
    }
}
✅ Spotlightに表示されます(方法1:オプションにする)
@Parameter(title: "Notes")
var notes: String? // Optional - can omit from summary
✅ Spotlightに表示されます(方法2:デフォルト値を指定する)
@Parameter(title: "Notes")
var notes: String = "" // Has default - can omit from summary
✅ Spotlightに表示されます(方法3:概要に含める)
static var parameterSummary: some ParameterSummary {
    Summary("Create '\(\.$title)' from \(\.$startDate) to \(\.$endDate)") {
        \.$notes // All required params included
    }
}
2. インテントを非表示にしない

ショートカットで非表示にしたインテントは、Spotlightにも表示されません。

// ❌ Hidden from Spotlight
static var isDiscoverable: Bool = false

// ❌ Hidden from Spotlight
static var assistantOnly: Bool = true

// ❌ Hidden from Spotlight
// Intent with no perform() method (widget configuration only)
候補を提示する

候補を提示して、パラメーターをすばやく入力できるようにします。

方法1:候補エンティティ(大量のリストの一部)
struct EventEntityQuery: EntityQuery {
    func entities(for identifiers: [UUID]) async throws -> [EventEntity] {
        return try await EventService.shared.fetchEvents(ids: identifiers)
    }

    // Provide upcoming events, not all past/present events
    func suggestedEntities() async throws -> [EventEntity] {
        return try await EventService.shared.upcomingEvents(limit: 10)
    }
}
方法2:すべてのエンティティ(小規模で件数が限られたリスト)
struct TimezoneQuery: EnumerableEntityQuery {
    func allEntities() async throws -> [TimezoneEntity] {
        // Small list - provide all
        return TimezoneEntity.allTimezones
    }
}

候補エンティティを使う場合 リストの件数が多い、または上限がない場合(カレンダーのイベント、メモ、連絡先など) すべてのエンティティを使う場合 リストが小規模で件数に上限がある場合(タイムゾーン、優先度、カテゴリなど)

画面上のコンテンツのタグ付け

現在アクティブなコンテンツを候補として提示します。

// In your detail view controller
func showEventDetail(_ event: Event) {
    let activity = NSUserActivity(activityType: "com.myapp.viewEvent")
    activity.persistentIdentifier = event.id.uuidString

    // Spotlight suggests this event for parameters
    activity.appEntityIdentifier = event.id.uuidString

    userActivity = activity
}

画面上のコンテンツのタグ付けについて詳しくは、「Exploring New Advances in App Intents」セッションをご覧ください。

候補以外も検索する

基本的な絞り込み(自動): 候補を指定すると、Spotlightはユーザーの入力に合わせて自動的に候補を絞り込みます。

詳細な検索(実装が必要): 候補以外も検索するには、次の方法があります。

方法1:EntityStringQuery
extension EventQuery: EntityStringQuery {
    func entities(matching string: String) async throws -> [EventEntity] {
        return try await EventService.shared.search(query: string)
    }
}
方法2:IndexedEntity
struct EventEntity: AppEntity, IndexedEntity {
    // Spotlight search automatically supported
}
バックグラウンドインテントとフォアグラウンドインテント
パターン:opensIntentを使ったペアのインテント
// Background intent - runs without opening app
struct CreateEventIntent: AppIntent {
    static var openAppWhenRun: Bool = false

    @Parameter(title: "Title")
    var title: String

    @Parameter(title: "Start Date")
    var startDate: Date

    func perform() async throws -> some IntentResult {
        let event = try await EventService.shared.createEvent(
            title: title,
            startDate: startDate
        )

        // Optionally open app to view created event
        return .result(
            value: EventEntity(from: event),
            opensIntent: OpenEventIntent(event: EventEntity(from: event))
        )
    }
}

// Foreground intent - opens app to specific event
struct OpenEventIntent: AppIntent {
    static var openAppWhenRun: Bool = true

    @Parameter(title: "Event")
    var event: EventEntity

    func perform() async throws -> some IntentResult {
        await MainActor.run {
            EventCoordinator.shared.showEvent(id: event.id)
        }
        return .result()
    }
}
ユーザー体験
  1. ユーザーがSpotlightで「イベントを作成」を実行する(バックグラウンド)
  2. アプリを開かずにイベントが作成される
  3. Spotlightに「アプリで開く」ボタンが表示される(opensIntent)
  4. ユーザーがボタンをタップすると、アプリがイベントの詳細画面を開く
予測可能なインテントプロトコル

使用パターンに基づくSpotlightの候補表示を有効にします。

struct OrderCoffeeIntent: AppIntent, PredictableIntent {
    static var title: LocalizedStringResource = "Order Coffee"

    @Parameter(title: "Coffee Type")
    var coffeeType: CoffeeType

    @Parameter(title: "Size")
    var size: CoffeeSize

    func perform() async throws -> some IntentResult {
        // Order logic
        return .result()
    }
}

Spotlightは、ユーザーがこのインテントをいつ、どのように実行するかを学習し、候補を先回りして表示します。


Macのオートメーション

概要

パーソナルオートメーションがmacOSに登場し(macOS Sequoia以降)、Mac固有のトリガーを利用できます。

新しい Mac オートメーションの種類
  • フォルダーオートメーション — フォルダーへのファイルの追加・削除時に起動
  • 外部ドライブオートメーション — ドライブの接続・取り外し時に起動
  • 時刻(iOS から)
  • Bluetooth(iOS から)
  • その他…

使用例 新しい請求書が ~/Documents/Invoices フォルダーに追加されるたびに、請求書処理のショートカットが自動実行されます。

自動的な利用可能性

macOS でインテントを利用できる場合、Mac のオートメーションの一部として実行するために、ショートカットでも利用できます。これには、macOS にインストール可能な iOS アプリも含まれます。

追加のコードは不要です — 既存のインテントはオートメーションで自動的に動作します。

プラットフォームサポート
struct ProcessInvoiceIntent: AppIntent {
    static var title: LocalizedStringResource = "Process Invoice"

    // Available on macOS automatically
    // Also works: iOS apps installed on Mac (Catalyst, Mac Catalyst)

    @Parameter(title: "Invoice")
    var invoice: FileEntity

    func perform() async throws -> some IntentResult {
        // Extract data, add to spreadsheet, etc.
        return .result()
    }
}
その他のシステム統合ポイント

オートメーションを使うと、インテントに次の場所からアクセスできるようになります。

  • Siri — 音声コマンド
  • ショートカットアプリ — 手動のワークフロー
  • Spotlight — クイックアクション
  • オートメーション — トリガーされたワークフロー
  • アクションボタン — ハードウェアトリガー(Apple Watch Ultra)
  • コントロールセンター — クイックコントロール
  • ウィジェット — インタラクティブ要素
  • ライブアクティビティ — ダイナミックアイランド

アシスタントスキーマ(ビルトインインテント)

Apple は、一般的なアプリカテゴリ向けのビルトインスキーマを提供しています。

Books アプリの例
import AppIntents
import BooksIntents

struct OpenBookIntent: BooksOpenBookIntent {
    @Parameter(title: "Book")
    var target: BookEntity

    func perform() async throws -> some IntentResult {
        await MainActor.run {
            BookReader.shared.open(book: target)
        }
        return .result()
    }
}
利用可能なアシスタントスキーマ
  • BooksIntents — ページ移動、ブックを開く、オーディオブックの再生、検索
  • BrowserIntents — タブのブックマーク、履歴の消去、ウィンドウの管理
  • CameraIntents — 撮影モード、デバイスの切り替え、開始・停止
  • EmailIntents — 下書きの管理、返信、転送、アーカイブ
  • PhotosIntents — アルバム・アセットの管理、編集、フィルタリング
  • PresentationsIntents — スライドの作成、メディアの挿入、再生
  • SpreadsheetsIntents — シートの管理、コンテンツの追加
  • DocumentsIntents — ファイルの管理、ページの操作、検索

テストとデバッグ

ショートカットアプリでのテスト
  1. ショートカットにインテントを追加する:

    • ショートカットアプリを開く
    • 「+」をタップして新しいショートカットを作成する
    • アプリ名を検索する
    • インテントを選択する
  2. パラメーター解決をテストする:

    • パラメーターを入力する
    • ショートカットを実行する
    • Xcode コンソールでログを確認する
  3. Siri でテストする:

    • 「Hey Siri、[your intent name]」
    • Siri がパラメーターの入力を求めるはずです
    • ダイアログのテキストと結果を確認する
Xcode インテントテスト
// In your app target, not tests
#if DEBUG
extension OrderSoupIntent {
    static func testIntent() async throws {
        let intent = OrderSoupIntent()
        intent.soup = SoupEntity(id: "1", name: "Tomato", price: 8.99)
        intent.quantity = 2

        let result = try await intent.perform()
        print("Result: \(result)")
    }
}
#endif
よくあるデバッグの問題
問題 1:インテントがショートカットに表示されない
// ❌ Problem: isDiscoverable = false or missing
struct MyIntent: AppIntent {
    // Missing isDiscoverable
}

// ✅ Solution: Make discoverable
struct MyIntent: AppIntent {
    static var isDiscoverable: Bool = true
}
問題 2:パラメーターが解決されない
// ❌ Problem: Missing defaultQuery
struct ProductEntity: AppEntity {
    var id: String
    // Missing defaultQuery
}

// ✅ Solution: Add query
struct ProductEntity: AppEntity {
    var id: String
    static var defaultQuery = ProductQuery()
}
問題 3:インテントがバックグラウンドでクラッシュする
// ❌ Problem: Accessing MainActor from background
func perform() async throws -> some IntentResult {
    UIApplication.shared.open(url) // Crash! MainActor only
    return .result()
}

// ✅ Solution: Use MainActor or openAppWhenRun
func perform() async throws -> some IntentResult {
    await MainActor.run {
        UIApplication.shared.open(url)
    }
    return .result()
}
問題 4:エンティティクエリが空の結果を返す
// ❌ Problem: entities(for:) not implemented
struct BookQuery: EntityQuery {
    // Missing entities(for:) implementation
}

// ✅ Solution: Implement required methods
struct BookQuery: EntityQuery {
    func entities(for identifiers: [UUID]) async throws -> [BookEntity] {
        return try await BookService.shared.fetchBooks(ids: identifiers)
    }

    func suggestedEntities() async throws -> [BookEntity] {
        return try await BookService.shared.recentBooks(limit: 10)
    }
}

ベストプラクティス

1. インテントの命名
❌ しない:一般的すぎる、または不明瞭な名前
static var title: LocalizedStringResource = "Do Thing"
static var title: LocalizedStringResource = "Process"
✅ する:動詞と名詞を使った具体的な名前
static var title: LocalizedStringResource = "Send Message"
static var title: LocalizedStringResource = "Book Appointment"
static var title: LocalizedStringResource = "Start Workout"
2. パラメーターの概要
❌ しない:技術的、または分かりにくい表現
static var parameterSummary: some ParameterSummary {
    Summary("Execute \(\.$action) with \(\.$target)")
}
✅ する:自然な言葉
static var parameterSummary: some ParameterSummary {
    Summary("Send \(\.$message) to \(\.$contact)")
}
// Siri: "Send 'Hello' to John"
3. エラーメッセージ
❌ しない:専門用語
throw MyError.validationFailed("Invalid parameter state")
✅ する:ユーザーに分かりやすい表現
throw MyError.outOfStock("Sorry, this item is currently unavailable")
4. エンティティの候補
❌ しない:すべてのエンティティを返す
func suggestedEntities() async throws -> [TaskEntity] {
    return try await TaskService.shared.allTasks() // Could be thousands!
}
✅ する:最近のもの、または関連するものに限定する
func suggestedEntities() async throws -> [TaskEntity] {
    return try await TaskService.shared.recentTasks(limit: 10)
}
5. 非同期処理
❌ しない:メインスレッドをブロックする
func perform() async throws -> some IntentResult {
    let data = URLSession.shared.synchronousDataTask(url) // Blocks!
    return .result()
}
✅ する:async/await を使う
func perform() async throws -> some IntentResult {
    let data = try await URLSession.shared.data(from: url)
    return .result()
}

実例

例 1:ワークアウト開始インテント
struct StartWorkoutIntent: AppIntent {
    static var title: LocalizedStringResource = "Start Workout"
    static var description: IntentDescription = "Starts a new workout session"
    static var openAppWhenRun: Bool = true

    @Parameter(title: "Workout Type")
    var workoutType: WorkoutType

    @Parameter(title: "Duration (minutes)")
    var duration: Int?

    static var parameterSummary: some ParameterSummary {
        Summary("Start \(\.$workoutType)") {
            \.$duration
        }
    }

    func perform() async throws -> some IntentResult {
        let workout = Workout(
            type: workoutType,
            duration: duration.map { TimeInterval($0 * 60) }
        )

        await MainActor.run {
            WorkoutCoordinator.shared.start(workout)
        }

        return .result(
            dialog: "Starting \(workoutType.displayName) workout"
        )
    }
}

enum WorkoutType: String, AppEnum {
    case running
    case cycling
    case swimming
    case yoga

    static var typeDisplayRepresentation: TypeDisplayRepresentation = "Workout Type"
    static var caseDisplayRepresentations: [WorkoutType: DisplayRepresentation] = [
        .running: "Running",
        .cycling: "Cycling",
        .swimming: "Swimming",
        .yoga: "Yoga"
    ]

    var displayName: String {
        switch self {
        case .running: return "running"
        case .cycling: return "cycling"
        case .swimming: return "swimming"
        case .yoga: return "yoga"
        }
    }
}
例 2:エンティティクエリを使ったタスクの追加
struct AddTaskIntent: AppIntent {
    static var title: LocalizedStringResource = "Add Task"
    static var description: IntentDescription = "Creates a new task"
    static var isDiscoverable: Bool = true

    @Parameter(title: "Title")
    var title: String

    @Parameter(title: "List")
    var list: TaskListEntity?

    @Parameter(title: "Due Date")
    var dueDate: Date?

    static var parameterSummary: some ParameterSummary {
        Summary("Add '\(\.$title)'") {
            \.$list
            \.$dueDate
        }
    }

    func perform() async throws -> some IntentResult {
        let task = try await TaskService.shared.createTask(
            title: title,
            list: list?.id,
            dueDate: dueDate
        )

        return .result(
            value: TaskEntity(from: task),
            dialog: "Task '\(title)' added"
        )
    }
}

struct TaskListEntity: AppEntity {
    var id: UUID
    var name: String
    var color: String

    static var typeDisplayRepresentation: TypeDisplayRepresentation = "List"

    var displayRepresentation: DisplayRepresentation {
        DisplayRepresentation(
            title: "\(name)",
            image: .init(systemName: "list.bullet")
        )
    }

    static var defaultQuery = TaskListQuery()
}

struct TaskListQuery: EntityQuery, EntityStringQuery {
    func entities(for identifiers: [UUID]) async throws -> [TaskListEntity] {
        return try await TaskService.shared.fetchLists(ids: identifiers)
    }

    func suggestedEntities() async throws -> [TaskListEntity] {
        // Provide user's favorite lists
        return try await TaskService.shared.favoriteLists(limit: 5)
    }

    func entities(matching string: String) async throws -> [TaskListEntity] {
        return try await TaskService.shared.searchLists(query: string)
    }
}

App Intents チェックリスト

App Store に提出する前に
  • ☐ すべてのインテントに、明確でローカライズされたタイトルと説明がある
  • ☐ パラメーターの概要が自然な表現になっている
  • ☐ エラーメッセージが技術的ではなく、ユーザーにわかりやすい
  • ☐ 認証ポリシーがデータの機密性に適合している
  • ☐ エンティティクエリが適切な数の候補(20 件未満)を返す
  • ☐ isDiscoverable が設定されたインテントがショートカットに表示される
  • ☐ 破壊的な操作では確認を求める
  • ☐ バックグラウンドインテントが MainActor にアクセスしない
  • ☐ フォアグラウンドインテントで openAppWhenRun = true を設定する
  • ☐ エンティティの displayRepresentation に意味のある情報が表示される
  • ☐ Siri の音声コマンドでテスト済み
  • ☐ ショートカットアプリでテスト済み
  • ☐ さまざまなパラメーターの組み合わせでテスト済み
  • ☐ サポートされているすべての言語でローカライズを確認済み

リソース

WWDC:2025-244、2025-275、2025-260

ドキュメント:/appintents、/appintents/appintent、/appintents/appentity、/Updates/AppIntents

スキル:axiom-app-shortcuts-ref、axiom-core-spotlight-ref、axiom-app-discoverability


覚えておきましょう。 App Intents は、Siri、ショートカット、システム機能を通じてユーザーがアプリを操作するための仕組みです。適切に設計されたインテントは、アプリの機能を自然に拡張し、Apple のエコシステム全体で価値を提供します。

コメント