The Action Button: A Shortcut, a One-Shot Request, and a Retry
~15 min · on-the-wrist, action-button, app-intents, shortcuts, cold-launch
Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"It doesn't look like you can assign it to the Action Button."
The Button Points at a Shortcut
An Apple Watch with an Action Button offers the most direct capture there is: press it without looking and speak. The owner went to assign the capture app to it and found no apps in the list, only system functions and shortcuts. That is how it works: the Action Button targets a shortcut, and no Info.plist key or entitlement registers an app for it. An app becomes selectable by publishing a shortcut: an App Intent together with an AppShortcutsProvider in the watch target. That provider is separate from the phone app's, and the watch app's bundle gets its own App Intents metadata. The phrases follow the same rule as on the phone: each contains \(.applicationName), or the build halts, and no metadata string may name the device, which the family's source-reading guard now checks in the watch sources too.
An Intent That Needs a Screen Says So
Dictation needs the microphone prompt and a view on screen, so the intent sets openAppWhenRun to true. Its perform() does not try to capture silently, which would turn the button into a no-op that looks like it worked. It arms a one-shot request and returns, and the first view consumes the request and opens the same dictation door the in-app button and the complication use.
Where that request lives matters. The button can launch a process that does not exist yet, so the request cannot sit in an observable object the app has not created. UserDefaults is enough, and reading the request clears it. That clearing is the whole behaviour: a request left armed would reopen the microphone every time the wearer returned to the app.
Unavailable Is a Moment
A cold launch from the button lands exactly where WatchKit has no visible interface controller yet, so the dictation door answers that it cannot open. One app treated that answer as a state and remembered "dictation unavailable" for the rest of the session, leaving the wearer typing on a watch after one unlucky instant. The root view keeps the wish instead: a press sets it, the view's appearance and a return to the active phase consume any armed request and try again, and the wish is cleared only when the door actually opens.
Proof Lives on the Wrist
App Shortcuts do not reliably run in the Simulator, the watch Simulator cannot prove dictation, and the Action Button exists only on hardware. Registration can be checked in the built bundle's metadata; the rest is accepted when a press on a real watch, with the app not running, opens dictation and the words arrive on the phone.
Code
A one-shot request that survives a cold launch, and the watch intent that arms it·swift
// LaunchRequest.swift
import Foundation
/// A one-shot request that survives a cold launch. The process that reads it may not exist yet when
/// the button is pressed, so it lives in UserDefaults, and reading it clears it: a request left armed
/// would reopen the microphone every time the wearer comes back to the app.
enum LaunchRequest {
private static let key = "spark.launch.speak"
static func arm(_ defaults: UserDefaults = .standard) {
defaults.set(true, forKey: key)
}
static func consume(_ defaults: UserDefaults = .standard) -> Bool {
guard defaults.bool(forKey: key) else { return false }
defaults.removeObject(forKey: key)
return true
}
}
// SpeakIntent.swift (watch target)
import AppIntents
import Foundation
/// The Action Button cannot point at an app, only at a shortcut. Publishing this intent through an
/// AppShortcutsProvider in the WATCH target is what makes Spark selectable there.
struct SpeakToSparkIntent: AppIntent {
static let title: LocalizedStringResource = "Speak to Spark"
static let description = IntentDescription("Opens Spark's dictation so you can capture a thought.")
static let openAppWhenRun = true // dictation needs the app on screen; never pretend to capture silently
@MainActor
func perform() async throws -> some IntentResult {
LaunchRequest.arm() // the first view consumes it
return .result()
}
}
struct SparkWatchShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(intent: SpeakToSparkIntent(),
phrases: ["Speak to \(.applicationName)"],
shortTitle: "Speak",
systemImageName: "mic.fill")
}
}
The root view keeps the wish until dictation actually opens·swift
import SwiftUI
struct SparkWatchRoot: View {
/// The dictation door from the dictation lesson: false while no controller is visible yet.
let openDictation: @MainActor () -> Bool
@Environment(\.scenePhase) private var phase
@State private var wantsDictation = false
var body: some View {
Button("Speak", systemImage: "mic.fill") { request() }
.onOpenURL { url in // the complication's tap, same door
if url.scheme == "spark", url.host() == "speak" { request() }
}
.onAppear {
if LaunchRequest.consume() { wantsDictation = true } // a cold press of the Action Button
attempt()
}
.onChange(of: phase) { _, now in
guard now == .active else { return }
if LaunchRequest.consume() { wantsDictation = true } // a press while the app was alive
attempt()
}
}
private func request() {
wantsDictation = true
attempt()
}
/// Unavailable is a moment, not a state: keep the wish until the door actually opens.
private func attempt() {
guard wantsDictation else { return }
if openDictation() { wantsDictation = false }
}
}
Add SpeakToSparkIntent, SparkWatchShortcuts and LaunchRequest to the watch target, and write a unit test proving a request is consumed exactly once. Build and confirm the watch app bundle carries its own App Intents metadata listing the intent. On a watch with an Action Button, assign the Spark shortcut, force-quit the app, press the button, and record whether dictation opens on the first press. If it does not, add the retry in SparkWatchRoot and try again.
Hint
Give LaunchRequest an injectable UserDefaults so the test uses a throwaway suite. The watch app's metadata sits in its own bundle under Watch/, beside the phone app's, and the upload gate should know to look there.
Progress
Progress is local-only — sign in to sync across devices.