~15 min · on-the-wrist, action-button, app-intents, shortcuts, cold-launch
Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"액션 버튼에는 지정 안 되는 거 같고."
버튼은 단축어를 가리켜
동작 버튼이 있는 Apple Watch라면 담기가 가장 간단해져. 안 보고 누르고 말하면 끝이야. 주인이 담기 앱을 거기 지정하려고 봤더니, 목록엔 앱이 없고 시스템 기능과 단축어만 있었어. 원래 그렇게 동작해. 동작 버튼은 단축어를 가리키고, 앱을 거기 등록하는 Info.plist 키나 엔타이틀먼트는 없어. 앱이 목록에 뜨려면 단축어를 내놓아야 해. 워치 타깃 안에 App Intent와 AppShortcutsProvider를 두면 돼. 이 제공자는 폰 앱 것과 별개고, 워치 앱 번들엔 자기만의 App Intents 메타데이터가 생겨. 문구 규칙은 폰과 같아. 문구마다 \(.applicationName)이 들어가야 하고, 빠지면 빌드가 멈춰. 메타데이터 문자열엔 어디에도 기기 이름을 넣으면 안 돼. 가족의 소스 읽기 관문은 이제 워치 소스까지 확인해.
화면이 필요한 인텐트는 그렇다고 말해
받아쓰기엔 마이크 권한 창과 화면에 뜬 뷰가 필요해서, 인텐트는 openAppWhenRun을 true로 둬. perform()은 조용히 담으려 들지 않아. 그랬다간 된 것처럼 보이는데 실제론 아무것도 안 하는 버튼이 되거든. 대신 한 번 쓰는 요청만 걸어두고 돌아가. 그러면 첫 뷰가 그 요청을 소비해서, 앱 안 버튼과 컴플리케이션이 쓰는 바로 그 받아쓰기 문을 열어.
그 요청을 어디 두느냐가 중요해. 버튼을 누르는 시점엔 앱 프로세스가 아직 없을 수도 있어. 그러니 앱이 만들지도 않은 관찰 객체엔 요청을 둘 수 없지. UserDefaults면 충분하고, 요청은 읽는 순간 지워. 이 지우는 동작이 핵심이야. 요청이 걸린 채로 남아 있으면, 워치를 찬 사람이 앱으로 돌아올 때마다 마이크가 다시 열릴 테니까.
"못 씀"은 잠깐이야
꺼져 있던 앱을 버튼으로 켜면, WatchKit에 아직 보이는 인터페이스 컨트롤러가 없는 바로 그 시점에 떨어져. 그래서 받아쓰기 문은 못 연다고 대답해. 어떤 앱은 그 대답을 상태로 취급해서 세션 내내 "받아쓰기 못 씀"을 기억했어. 운 나쁜 한순간 때문에, 그 뒤로 워치를 찬 사람은 워치에서 타자를 쳐야 했지. 루트 뷰는 그러지 않고 바람을 쥐고 있어. 누르면 바람이 생겨. 뷰가 뜰 때와 활성 단계로 돌아올 때마다 걸린 요청을 소비하고 다시 시도하고, 바람은 문이 실제로 열렸을 때만 지워져.
증명은 손목에서
App Shortcuts는 시뮬레이터에서 제대로 돈다고 믿기 어렵고, 워치 시뮬레이터로는 받아쓰기를 증명할 수 없고, 동작 버튼은 실제 하드웨어에만 있어. 등록은 빌드된 번들 메타데이터로 확인할 수 있어. 나머지는 진짜 워치에서 확인해. 앱이 꺼져 있을 때 버튼을 눌러 받아쓰기가 열리고, 한 말이 폰에 도착하면 통과야.
Code
꺼진 앱을 새로 켤 때도 살아남는 한 번 쓰는 요청과, 그 요청을 거는 워치 인텐트·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")
}
}
받아쓰기가 실제로 열릴 때까지 바람을 쥐고 있는 루트 뷰·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 }
}
}
워치 타깃에 SpeakToSparkIntent, SparkWatchShortcuts, LaunchRequest를 넣고, 요청이 딱 한 번만 소비되는지 확인하는 단위 테스트를 써. 빌드한 다음 워치 앱 번들에 그 인텐트가 적힌 자체 App Intents 메타데이터가 있는지 확인해. 동작 버튼이 있는 워치에서 Spark 단축어를 지정하고 앱을 강제 종료한 뒤, 버튼을 눌러서 첫 번째에 받아쓰기가 열리는지 적어. 안 열리면 SparkWatchRoot에 재시도를 넣고 다시 해봐.
Hint
LaunchRequest가 UserDefaults를 주입받게 하면, 테스트에선 쓰고 버리는 suite를 넘길 수 있어. 워치 앱 메타데이터는 폰 앱 것과 나란히 Watch/ 아래 워치 앱 번들 안에 있어. 업로드 관문도 거기를 봐야 해.
Progress
Progress is local-only — sign in to sync across devices.