The Action Button: A Control, a One-Shot Request, and a Retry
~15 min · on-the-wrist, action-button, app-intents, controls, cold-launch
Level 0Bundle Opener
0 XP0/84 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 Runs a Control
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. No Info.plist key or entitlement registers an app there. Since watchOS 26 the road in is a control: a ControlWidget in the watch app's widget extension, the same kind of control that sits in Control Center and the Smart Stack. The wearer picks it under Settings → Action Button → Action, and a press runs the control's App Intent. The extension that already carries the complication carries the control too, registered in the same WidgetBundle behind if #available(watchOS 26.0, *). Metadata strings follow the phone's rule: no string may name the device, which the family's source-reading guard checks in the watch sources as well.
An Intent That Needs a Screen Says So
Dictation needs the microphone prompt and a view on screen, so the control's intent sets openAppWhenRun to true and, on watchOS 26, declares supportedModes as .foreground(.immediate). Its perform() does not try to capture silently, which would turn the button into a no-op that looks like it worked. Where it runs decides what it can do. The conversation app's first control ran its own intent inside the widget extension and returned an OpenURLIntent for the app's URL; on the owner's wrist the control flashed and the watch face came back, and the app never opened. The intent that works is compiled into both the watch app and its widget extension. When the app carries the same intent type, the system runs an app-opening intent in the app's process, where a hook the app set at launch takes over: it opens the dictation door if the app is already in front, and otherwise arms a one-shot request for the first view to pick up, because a door opened while the app is still coming up lands on a screen that is not there yet. Inside the extension, where that hook is nil, the intent can only leave the same request.
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, with one catch: each target's UserDefaults.standard is its own domain, so a request the extension might write has to go into an App Group suite that both the watch app and the extension are entitled to. 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
The watch Simulator cannot prove dictation, and the Action Button exists only on hardware. Registration can be checked in the built bundles: the App Intents metadata of both the watch app and its widget extension should list the intent with openAppWhenRun set. 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, the intent compiled into the app and its extension, and the control that runs it·swift
// LaunchRequest.swift (watch app and widget extension)
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"
/// An App Group suite both the watch app and its widget extension are entitled to. Each
/// target's UserDefaults.standard is its own domain: a flag the extension writes there
/// is a flag the app never reads.
static var shared: UserDefaults { UserDefaults(suiteName: "group.com.example.spark.watch") ?? .standard }
static func arm(_ defaults: UserDefaults = shared) {
defaults.set(true, forKey: key)
}
static func consume(_ defaults: UserDefaults = shared) -> Bool {
guard defaults.bool(forKey: key) else { return false }
defaults.removeObject(forKey: key)
return true
}
}
// SpeakIntent.swift: compiled into BOTH the watch app and its widget extension. When the app carries
// the same type, the system runs this app-opening intent in the app's process.
import AppIntents
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
@available(watchOS 26.0, *)
static var supportedModes: IntentModes { .foreground(.immediate) }
/// Set by the watch app at launch. nil in the widget extension.
@MainActor static var onPerform: (@MainActor () -> Void)?
@MainActor
func perform() async throws -> some IntentResult {
if let start = Self.onPerform {
start() // in the app: the hook decides, door now or request for later
} else {
LaunchRequest.arm() // in the extension: into the shared suite, for the app's first view
}
return .result()
}
}
// SpeakControl.swift (widget extension): the Action Button's road since watchOS 26.
import SwiftUI
import WidgetKit
@available(watchOS 26.0, *)
struct SpeakControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "com.example.spark.control.speak") {
ControlWidgetButton(action: SpeakToSparkIntent()) {
Label("Speak to Spark", systemImage: "mic.fill")
}
}
.displayName("Speak to Spark")
.description("Opens dictation.")
}
}
// In the extension's WidgetBundle, beside the complication:
// SparkComplication()
// if #available(watchOS 26.0, *) { SpeakControl() }
//
// In the watch app's App.init: open the door when the app is already in front; while it is
// still coming up, leave the request for the first view instead.
// SpeakToSparkIntent.onPerform = {
// if WKApplication.shared().applicationState == .active { /* open the dictation door */ }
// else { LaunchRequest.arm() }
// }
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 }
}
}
Give the watch app and its widget extension one App Group, add LaunchRequest and SpeakToSparkIntent to both, add SpeakControl to the extension's widget bundle, and set onPerform in the watch app's initializer. Write a unit test proving a request is consumed exactly once. Build and confirm that the App Intents metadata of both bundles lists the intent with openAppWhenRun. On a watch with an Action Button running watchOS 26, assign the control under Settings → Action Button → Action, force-quit the app, press the button, and record whether dictation opens on the first press. Then remove the intent from the app target, so it lives only in the extension, and record what a press does.
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/, and the extension's inside the watch app's PlugIns; the upload gate should know to look in both. If the second press experiment only flashes the control and returns to the face, you have reproduced the first build's failure.
Progress
Progress is local-only — sign in to sync across devices.