feat(siri): background Live Activity, spoken action names, localized phrases

시리 단축어 점검에서 나온 추천 수정 일괄 적용:

- StartTimeActionIntent·StopTimeActionIntent가 LiveActivityIntent 채택 —
  시리/단축어의 백그라운드 실행에서도 Live Activity 시작이 허용되어
  앱을 열지 않아도 다이나믹 아일랜드에 측정이 표시된다.
  RunActionIntent(위젯 버튼)는 확장 프로세스 응답성 튜닝을 유지하기 위해
  의도적으로 제외.
- 행동 이름 파라미터 문구 추가: "시리야, 하루 다님에서 독서 측정 시작"처럼
  되묻지 않고 한 번에 실행. 어휘 갱신을 위해 앱 시작 시
  updateAppShortcutParameters() 호출 (새 행동은 다음 실행부터 인식).
- IOS/AppShortcuts.xcstrings 신설 — 시리 문구 ko/en/ja 지역화.
  빌드 로그의 --no-app-shortcuts-localization가 사라지고 root.ssu.yaml에
  en/ja 발화가 포함되는 것 확인.
- RunActionIntent isDiscoverable=false — 생 UUID 파라미터라 사용자가 조합할
  수 없는 위젯 전용 동작을 단축어 앱·스포트라이트에서 숨김 (위젯 버튼은
  그대로 동작).
- 결제 화면 기능 목록에 '시리 단축어' 추가. 이 과정에서 featureRow가
  String 파라미터라 기능 목록 6줄 전체가 미지역화였던 버그 발견 —
  LocalizedStringKey로 바꾸고 en/ja 12개 문자열 번역.

검증: -intentSmokeTest 9개 시나리오 전부 통과(LiveActivityIntent 전환 후
재확인), 프리미엄 화면 렌더링 확인, Debug·Store 스킴 빌드 성공.
남은 확인: 시리 음성 인식·백그라운드 Live Activity 실표시는 실기기 필요.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
songyc macbook 2026-07-13 21:08:48 +09:00
parent ecfd008229
commit ec62dc83de
8 changed files with 373 additions and 6 deletions

View File

@ -259,7 +259,9 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${files[@]}"
## 12. 시리 단축어 (`Shared/HaruDanimIntents.swift`, 프리미엄 게이트)
인텐트: 측정 시작/종료(짧은 기록 무시 규칙 동일 적용) · 횟수 추가(1~999) · 행동 누적값 조회 · 목표 진행률 조회 · 다짐 진행률 조회(atMost는 한도 문구). `AppShortcutsProvider`에 대표 문구 등록(현재 한국어 전용 — AppShortcuts 지역화 미구성, 빌드 로그 `--no-app-shortcuts-localization`). 엔티티(Action/Goal/Quest)는 EntityStringQuery + '선택 안 함' 항목(위젯 설정용 센티널이 단축어 앱 선택지에도 노출됨). 도움말 프리미엄 그룹에 사용법 항목 있음. 로직 검증은 `-intentSmokeTest` (§14) — perform() 9개 시나리오를 앱 안에서 실행하고 결과를 Documents/intent-smoke-test.txt로 남긴다.
인텐트: 측정 시작/종료(짧은 기록 무시 규칙 동일 적용, **LiveActivityIntent 채택** — 시리 백그라운드 실행에서도 다이나믹 아일랜드 시작 허용) · 횟수 추가(1~999) · 행동 누적값 조회 · 목표 진행률 조회 · 다짐 진행률 조회(atMost는 한도 문구). `RunActionIntent`(위젯 버튼 전용)는 `isDiscoverable=false`로 단축어 앱에서 숨김이며 확장 프로세스 응답성 유지를 위해 LiveActivityIntent를 채택하지 않는다.
- **시리 문구**: `AppShortcutsProvider`에 기본 + 행동 이름 파라미터 문구("하루 다님에서 \(\.$action) 측정 시작" — 되묻지 않고 한 번에 실행). 번역은 **`IOS/AppShortcuts.xcstrings`**(일반 카탈로그와 별개, ko/en/ja). 행동 어휘는 앱 시작 시 `updateAppShortcutParameters()`로 갱신(새 행동은 다음 실행부터 음성 인식).
- 엔티티(Action/Goal/Quest)는 EntityStringQuery + '선택 안 함' 항목(위젯 설정용 센티널이 단축어 앱 선택지에도 노출됨 — 알려진 트레이드오프). 도움말 프리미엄 그룹에 사용법 항목, 결제 화면 기능 목록에도 표기. 로직 검증은 `-intentSmokeTest` (§14) — perform() 9개 시나리오를 앱 안에서 실행하고 결과를 Documents/intent-smoke-test.txt로 남긴다.
---

View File

@ -601,8 +601,8 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "하루 다님";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.lifestyle";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "내보낸 리포트 이미지를 사진 앨범에 저장하기 위해 필요해요.";
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "일기 타임테이블에 캘린더 일정을 함께 표시하기 위해 필요해요.";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "내보낸 리포트 이미지를 사진 앨범에 저장하기 위해 필요해요.";
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@ -642,8 +642,8 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "하루 다님";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.lifestyle";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "내보낸 리포트 이미지를 사진 앨범에 저장하기 위해 필요해요.";
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "일기 타임테이블에 캘린더 일정을 함께 표시하기 위해 필요해요.";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "내보낸 리포트 이미지를 사진 앨범에 저장하기 위해 필요해요.";
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;

View File

@ -0,0 +1,146 @@
{
"sourceLanguage" : "ko",
"strings" : {
"${applicationName} 목표 진행률 알려 줘" : {
"localizations" : {
"en" : {
"stringSet" : {
"state" : "translated",
"values" : [
"Show my goal progress in ${applicationName}",
"What's my goal progress in ${applicationName}"
]
}
},
"ja" : {
"stringSet" : {
"state" : "translated",
"values" : [
"${applicationName}の目標達成率を教えて"
]
}
},
"ko" : {
"stringSet" : {
"state" : "translated",
"values" : [
"${applicationName} 목표 진행률 알려 줘"
]
}
}
}
},
"${applicationName}에서 측정 시작" : {
"localizations" : {
"en" : {
"stringSet" : {
"state" : "translated",
"values" : [
"Start tracking in ${applicationName}",
"Start a timer in ${applicationName}",
"Start tracking ${action} in ${applicationName}",
"Start ${action} in ${applicationName}"
]
}
},
"ja" : {
"stringSet" : {
"state" : "translated",
"values" : [
"${applicationName}で計測開始",
"${applicationName}で計測を始めて",
"${applicationName}で${action}の計測を開始",
"${applicationName}で${action}を始めて"
]
}
},
"ko" : {
"stringSet" : {
"state" : "translated",
"values" : [
"${applicationName}에서 측정 시작",
"${applicationName} 시간 측정 시작해 줘",
"${applicationName}에서 ${action} 측정 시작",
"${applicationName}에서 ${action} 시작해 줘"
]
}
}
}
},
"${applicationName}에서 측정 종료" : {
"localizations" : {
"en" : {
"stringSet" : {
"state" : "translated",
"values" : [
"Stop tracking in ${applicationName}",
"Stop the timer in ${applicationName}",
"Stop tracking ${action} in ${applicationName}",
"Stop ${action} in ${applicationName}"
]
}
},
"ja" : {
"stringSet" : {
"state" : "translated",
"values" : [
"${applicationName}で計測終了",
"${applicationName}で計測を終えて",
"${applicationName}で${action}の計測を終了",
"${applicationName}で${action}を終えて"
]
}
},
"ko" : {
"stringSet" : {
"state" : "translated",
"values" : [
"${applicationName}에서 측정 종료",
"${applicationName} 시간 측정 끝내 줘",
"${applicationName}에서 ${action} 측정 종료",
"${applicationName}에서 ${action} 끝내 줘"
]
}
}
}
},
"${applicationName}에서 횟수 추가" : {
"localizations" : {
"en" : {
"stringSet" : {
"state" : "translated",
"values" : [
"Add a count in ${applicationName}",
"Log something in ${applicationName}",
"Add a ${action} count in ${applicationName}",
"Log ${action} in ${applicationName}"
]
}
},
"ja" : {
"stringSet" : {
"state" : "translated",
"values" : [
"${applicationName}で回数を追加",
"${applicationName}に記録して",
"${applicationName}で${action}の回数を追加",
"${applicationName}に${action}を記録して"
]
}
},
"ko" : {
"stringSet" : {
"state" : "translated",
"values" : [
"${applicationName}에서 횟수 추가",
"${applicationName}에 기록 추가해 줘",
"${applicationName}에서 ${action} 횟수 추가",
"${applicationName}에 ${action} 기록해 줘"
]
}
}
}
}
},
"version" : "1.0"
}

View File

@ -8,12 +8,18 @@
import AppIntents
struct HaruDanimShortcuts: AppShortcutsProvider {
// (\(\.$action)) ", "
// .
// updateAppShortcutParameters() (Haru_DanimApp.init).
// IOS/AppShortcuts.xcstrings ( ).
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: StartTimeActionIntent(),
phrases: [
"\(.applicationName)에서 측정 시작",
"\(.applicationName) 시간 측정 시작해 줘",
"\(.applicationName)에서 \(\.$action) 측정 시작",
"\(.applicationName)에서 \(\.$action) 시작해 줘",
],
shortTitle: "측정 시작",
systemImageName: "play.circle.fill"
@ -23,6 +29,8 @@ struct HaruDanimShortcuts: AppShortcutsProvider {
phrases: [
"\(.applicationName)에서 측정 종료",
"\(.applicationName) 시간 측정 끝내 줘",
"\(.applicationName)에서 \(\.$action) 측정 종료",
"\(.applicationName)에서 \(\.$action) 끝내 줘",
],
shortTitle: "측정 종료",
systemImageName: "stop.circle.fill"
@ -32,6 +40,8 @@ struct HaruDanimShortcuts: AppShortcutsProvider {
phrases: [
"\(.applicationName)에서 횟수 추가",
"\(.applicationName)에 기록 추가해 줘",
"\(.applicationName)에서 \(\.$action) 횟수 추가",
"\(.applicationName)\(\.$action) 기록해 줘",
],
shortTitle: "횟수 추가",
systemImageName: "plus.circle.fill"

View File

@ -5,6 +5,7 @@
// Created by on 7/7/26.
//
import AppIntents
import SwiftUI
import SwiftData
@ -31,6 +32,8 @@ struct Haru_DanimApp: App {
WatchSyncManager.shared.activate()
// CloudKit ( · ) ··
CloudSyncMonitor.shared.start(container: container)
// (\(\.$action) ) /
HaruDanimShortcuts.updateAppShortcutParameters()
}
var body: some Scene {

View File

@ -1,6 +1,22 @@
{
"sourceLanguage": "ko",
"strings": {
"\"시리야, 하루 다님에서 측정 시작\" — 말과 단축어 자동화로 기록해요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "\"Hey Siri, start tracking in HaruDanim\" — track by voice and Shortcuts automations."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "「Hey Siri、ハルダニムで計測開始」— 声とショートカットの自動化で記録できます。"
}
}
}
},
"%@": {
"localizations": {
"en": {
@ -972,6 +988,22 @@
}
}
},
"iCloud로 기기 간 데이터가 동기화돼요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Your data syncs across devices via iCloud."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "iCloudでデバイス間のデータが同期されます。"
}
}
}
},
"iCloud로 아이패드 등 다른 기기와 데이터가 동기화돼요. 변경 사항은 앱을 완전히 종료했다가 다시 실행하면 적용돼요.": {
"localizations": {
"en": {
@ -1198,6 +1230,22 @@
}
}
},
"개수 제한 해제": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Unlimited items"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "個数制限の解除"
}
}
}
},
"건강": {
"localizations": {
"en": {
@ -3775,6 +3823,7 @@
}
},
"목표 달성률을 ${span} 기준 ${style}(으)로 표시": {
"extractionState": "stale",
"localizations": {
"en": {
"stringUnit": {
@ -5494,6 +5543,22 @@
}
}
},
"시리 단축어": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Siri Shortcuts"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Siriショートカット"
}
}
}
},
"시리 단축어는 프리미엄 기능이에요. 하루 다님 앱의 설정 → 프리미엄에서 잠금 해제해 주세요.": {
"localizations": {
"en": {
@ -5670,6 +5735,38 @@
}
}
},
"아이패드 동기화": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "iPad sync"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "iPad同期"
}
}
}
},
"아이패드 일기": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "iPad diary"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "iPadダイアリー"
}
}
}
},
"아주 넓게": {
"localizations": {
"en": {
@ -5751,6 +5848,22 @@
}
}
},
"애플워치 앱": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Apple Watch app"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Apple Watchアプリ"
}
}
}
},
"앱 설정과 일치": {
"localizations": {
"en": {
@ -6231,6 +6344,22 @@
}
}
},
"워치에서 추적하고 컴플리케이션으로 확인해요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Track on your watch and check complications at a glance."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ウォッチで記録し、コンプリケーションで確認できます。"
}
}
}
},
"워치와 아이폰은 근처에 있을 때 자동으로 맞춰져요. 값이 다르게 보이면 워치 앱을 아래로 당겨 새로고침하거나, 아이폰에서 앱을 한 번 열어 주세요. 모음 탭의 새로고침 버튼도 도움이 돼요.": {
"localizations": {
"en": {
@ -6424,6 +6553,22 @@
}
}
},
"위젯에서 바로 행동을 실행하고 달성률을 확인해요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Run actions and check progress right from widgets."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ウィジェットから直接アクションを実行し、達成率を確認できます。"
}
}
}
},
"음식": {
"localizations": {
"en": {
@ -8092,6 +8237,7 @@
}
},
"측정 시작": {
"extractionState": "stale",
"localizations": {
"en": {
"stringUnit": {
@ -8124,6 +8270,7 @@
}
},
"측정 종료": {
"extractionState": "stale",
"localizations": {
"en": {
"stringUnit": {
@ -9117,6 +9264,22 @@
}
}
},
"하루 정리와 애플 펜슬 다이어리를 사용해요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Wrap up your day and journal with Apple Pencil."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "1日のまとめとApple Pencilダイアリーが使えます。"
}
}
}
},
"하루 통계": {
"localizations": {
"en": {
@ -9722,6 +9885,22 @@
}
}
},
"행동·목표·다짐을 무제한으로 만들 수 있어요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Create unlimited actions, goals, and quests."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "アクション・目標・クエストを無制限に作れます。"
}
}
}
},
"행동별 시간 (일별)": {
"localizations": {
"en": {
@ -9914,6 +10093,22 @@
}
}
},
"홈·잠금화면 위젯": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Home & Lock Screen widgets"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ホーム・ロック画面ウィジェット"
}
}
}
},
"화": {
"comment": "요일 축약(화요일)",
"localizations": {

View File

@ -344,6 +344,7 @@ struct PremiumView: View {
featureRow("icloud.fill", "아이패드 동기화", "iCloud로 기기 간 데이터가 동기화돼요.")
featureRow("applewatch", "애플워치 앱", "워치에서 추적하고 컴플리케이션으로 확인해요.")
featureRow("book.closed.fill", "아이패드 일기", "하루 정리와 애플 펜슬 다이어리를 사용해요.")
featureRow("mic.fill", "시리 단축어", "\"시리야, 하루 다님에서 측정 시작\" — 말과 단축어 자동화로 기록해요.")
} header: {
Text("프리미엄으로 할 수 있는 것")
} footer: {
@ -552,7 +553,9 @@ struct PremiumView: View {
}
}
private func featureRow(_ symbol: String, _ title: String, _ detail: String) -> some View {
// title·detail LocalizedStringKey String
// · ( )
private func featureRow(_ symbol: String, _ title: LocalizedStringKey, _ detail: LocalizedStringKey) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: symbol)
.font(.system(size: 18))

View File

@ -383,6 +383,10 @@ struct RunActionIntent: AppIntent {
static let title: LocalizedStringResource = "행동 실행"
static let description = IntentDescription("시간 측정 행동은 시작/종료를 전환하고, 횟수 기록 행동은 횟수를 1 추가해요.")
/// · UUID
/// (isDiscoverable false Button(intent:) ).
static let isDiscoverable = false
/// AppEntity UUID .
/// AppEntity EntityQuery ( ModelContainer
/// ) perform , "
@ -436,7 +440,10 @@ enum ActionRunner {
// MARK: - :
struct StartTimeActionIntent: AppIntent {
/// LiveActivityIntent : / Live Activity
/// , . (
/// RunActionIntent )
struct StartTimeActionIntent: LiveActivityIntent {
static let title: LocalizedStringResource = "시간 측정 시작"
static let description = IntentDescription("시간 측정 행동의 측정을 시작해요.")
@ -463,7 +470,8 @@ struct StartTimeActionIntent: AppIntent {
// MARK: - :
struct StopTimeActionIntent: AppIntent {
/// LiveActivityIntent StartTimeActionIntent ( Live Activity /)
struct StopTimeActionIntent: LiveActivityIntent {
static let title: LocalizedStringResource = "시간 측정 종료"
static let description = IntentDescription("측정 중인 행동의 시간 측정을 종료해요.")