fix(watch): 1.5.1 — 탭 이중 실행 방어(commandID)·미니 화면 접수 피드백 후 자동 메인 복귀

- 가끔 횟수 +2 실측: 응답 유실 시 큐 재전송이 같은 명령을 두 번 실행 —
  run 명령에 탭당 고유 commandID 동봉, iPhone이 최근 64개 기억해 중복 무시
- 컴플리케이션 미니 화면: 탭 → 체크 오버레이+눌림 축소(기존 진동과 함께)
  0.7초 → 자동 메인 복귀, 접수 중 재탭 무시 (놔둔 채 오탭 방지 — 사용자 요구)
- QA: -watchDetailAutoTap 신설, 시드 모드 미활성 세션 전송 가드
- 검증: 4종 빌드 그린, 워치 26.2·11.5 시각 QA, 페어링 E2E 실행 1회·중복 0
- MARKETING_VERSION 1.5.1(빌드 1), whats-new-1.5.1 3언어

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
This commit is contained in:
songyc macbook 2026-09-02 14:12:03 +09:00
parent eba01b570f
commit 7799998a2d
7 changed files with 124 additions and 23 deletions

File diff suppressed because one or more lines are too long

View File

@ -601,7 +601,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = IOS/Haru_Danim.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -620,7 +620,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.5;
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = com.yechan.HaruDanim;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@ -643,7 +643,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = IOS/Haru_Danim.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -662,7 +662,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.5;
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = com.yechan.HaruDanim;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@ -686,7 +686,7 @@
CODE_SIGN_ENTITLEMENTS = "Haru_DanimWatch Watch App/Haru_DanimWatch Watch App.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -697,7 +697,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.5;
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = com.yechan.HaruDanim.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -722,7 +722,7 @@
CODE_SIGN_ENTITLEMENTS = "Haru_DanimWatch Watch App/Haru_DanimWatch Watch App.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -733,7 +733,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.5;
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = com.yechan.HaruDanim.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -758,7 +758,7 @@
CODE_SIGN_ENTITLEMENTS = Haru_DanimWatchWidgets/Haru_DanimWatchWidgetsExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Haru_DanimWatchWidgets/Info.plist;
@ -770,7 +770,7 @@
"@executable_path/../../Frameworks",
"@executable_path/../../../../Frameworks",
);
MARKETING_VERSION = 1.5;
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "com.yechan.HaruDanim.watchkitapp.Haru-DanimWatchWidgets";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -794,7 +794,7 @@
CODE_SIGN_ENTITLEMENTS = Haru_DanimWatchWidgets/Haru_DanimWatchWidgetsExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Haru_DanimWatchWidgets/Info.plist;
@ -806,7 +806,7 @@
"@executable_path/../../Frameworks",
"@executable_path/../../../../Frameworks",
);
MARKETING_VERSION = 1.5;
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "com.yechan.HaruDanim.watchkitapp.Haru-DanimWatchWidgets";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -827,7 +827,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = Widgets/Haru_DanimWidgets.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Widgets/Info.plist;
@ -838,7 +838,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.5;
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = com.yechan.HaruDanim.Widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@ -853,7 +853,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = Widgets/Haru_DanimWidgets.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Widgets/Info.plist;
@ -864,7 +864,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.5;
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = com.yechan.HaruDanim.Widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;

View File

@ -11,6 +11,9 @@
// , (//1 ) .
// : + + .
// (40mm) .
// - 1.5.1: ( + , )
// ( ).
// ( iPhone commandID ).
//
import SwiftUI
@ -18,8 +21,14 @@ import WatchKit
struct WatchActionDetailView: View {
@State private var store = WatchStore.shared
@Environment(\.dismiss) private var dismiss
let actionID: UUID
/// (1.5.1) + ,
/// ( ).
///
@State private var confirmed = false
/// (40·41mm)
/// · ( )
private var compact: Bool { WKInterfaceDevice.current().screenBounds.height < 220 }
@ -38,6 +47,29 @@ struct WatchActionDetailView: View {
}
}
.navigationBarTitleDisplayMode(.inline)
#if DEBUG
// -watchDetailAutoTap YES: 2
// · CLI (1.5.1)
.onAppear {
guard UserDefaults.standard.bool(forKey: "watchDetailAutoTap") else { return }
Task { @MainActor in
try? await Task.sleep(for: .seconds(2))
if let action { fire(action) }
}
}
#endif
}
/// = + + (1.5.1 )
private func fire(_ action: WatchActionInfo) {
guard !confirmed else { return }
store.run(action)
withAnimation(.spring(duration: 0.25)) { confirmed = true }
Task { @MainActor in
// ·
try? await Task.sleep(for: .milliseconds(700))
dismiss()
}
}
private func detail(_ action: WatchActionInfo) -> some View {
@ -75,7 +107,7 @@ struct WatchActionDetailView: View {
let runningTime = action.isRunning && !action.isCount
let side: CGFloat = compact ? 54 : 64
return Button {
store.run(action)
fire(action)
} label: {
Image(safeSymbol: action.symbolName)
.font(.system(size: compact ? 22 : 26, weight: .semibold))
@ -94,6 +126,18 @@ struct WatchActionDetailView: View {
.overlay(alignment: .bottomTrailing) {
actionBadge(action, runningTime: runningTime)
}
// + ( )
.overlay {
if confirmed {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.black.opacity(0.35))
Image(systemName: "checkmark")
.font(.system(size: compact ? 24 : 28, weight: .bold))
.foregroundStyle(.white)
.transition(.scale.combined(with: .opacity))
}
}
.scaleEffect(confirmed ? 0.9 : 1)
//
.contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}

View File

@ -63,14 +63,22 @@ final class WatchStore: NSObject {
// .
// .click (1.4) .success
WKInterfaceDevice.current().play(.success)
// commandID: 1 = ID 1
// iPhone ID (1.5.1 +2 )
send([
WatchSync.commandKey: WatchSync.runCommand,
WatchSync.actionIDKey: action.id.uuidString,
WatchSync.sentAtKey: Date.now.timeIntervalSince1970,
WatchSync.commandIDKey: UUID().uuidString,
])
}
private func send(_ message: [String: Any]) {
#if DEBUG
// (-watchSeedSnapshot)
// ( transferUserInfo QA 1.5.1 auto-tap QA)
if UserDefaults.standard.bool(forKey: "watchSeedSnapshot") { return }
#endif
let session = WCSession.default
guard session.activationState == .activated else {
// ( )

View File

@ -211,13 +211,30 @@ final class WatchSyncManager: NSObject {
// MARK:
/// ID (1.5.1) sendMessage
/// transferUserInfo ( 1 +2).
/// ID . (ID )
private var handledRunCommandIDs: [String] = []
private func isDuplicateRunCommand(_ commandID: String?) -> Bool {
guard let commandID else { return false }
if handledRunCommandIDs.contains(commandID) { return true }
handledRunCommandIDs.append(commandID)
if handledRunCommandIDs.count > 64 {
handledRunCommandIDs.removeFirst(handledRunCommandIDs.count - 64)
}
return false
}
///
fileprivate func handle(command: String?, actionID: String?) -> [String: Any] {
fileprivate func handle(command: String?, actionID: String?, commandID: String?) -> [String: Any] {
let context = DataStore.shared.mainContext
if command == WatchSync.runCommand,
let idString = actionID,
let id = UUID(uuidString: idString) {
if PremiumGate.isUnlocked(.watchApp) {
if isDuplicateRunCommand(commandID) {
watchSyncLog.notice("run command duplicate ignored: \(idString, privacy: .public)")
} else if PremiumGate.isUnlocked(.watchApp) {
let actions = (try? context.fetch(FetchDescriptor<Action>())) ?? []
if let action = actions.first(where: { $0.uuid == id }) {
watchSyncLog.notice("run command: \(action.name, privacy: .public)")
@ -264,8 +281,11 @@ extension WatchSyncManager: WCSessionDelegate {
) {
let command = message[WatchSync.commandKey] as? String
let actionID = message[WatchSync.actionIDKey] as? String
let commandID = message[WatchSync.commandIDKey] as? String
Task { @MainActor in
let reply = WatchSyncManager.shared.handle(command: command, actionID: actionID)
let reply = WatchSyncManager.shared.handle(
command: command, actionID: actionID, commandID: commandID
)
replyHandler(reply)
WatchSyncManager.shared.pushSnapshot()
}
@ -275,6 +295,7 @@ extension WatchSyncManager: WCSessionDelegate {
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
let command = userInfo[WatchSync.commandKey] as? String
let actionID = userInfo[WatchSync.actionIDKey] as? String
let commandID = userInfo[WatchSync.commandIDKey] as? String
watchSyncLog.notice("didReceiveUserInfo command=\(command ?? "nil", privacy: .public)")
guard command == WatchSync.runCommand else { return }
//
@ -283,7 +304,9 @@ extension WatchSyncManager: WCSessionDelegate {
return
}
Task { @MainActor in
_ = WatchSyncManager.shared.handle(command: command, actionID: actionID)
_ = WatchSyncManager.shared.handle(
command: command, actionID: actionID, commandID: commandID
)
WatchSyncManager.shared.pushSnapshot()
}
}

View File

@ -0,0 +1,22 @@
# 버전 1.5.1 '새로운 기능' (로케일별)
[ko]
애플워치 사용성을 다듬은 작은 업데이트예요.
• 워치 컴플리케이션으로 여는 행동 화면에서 버튼을 누르면, 접수 표시(체크+진동)를 보여 준 뒤 자동으로 첫 화면에 돌아가요 — 화면이 열린 채 실수로 또 눌리는 일을 막아요
• 워치에서 누른 기록이 아주 가끔 두 번 반영되던 문제를 고쳤어요
• 여러 기기를 쓰신다면 모든 기기에서 함께 업데이트해 주세요
[en]
A small update polishing the Apple Watch experience.
• On the action screen opened from a watch complication, tapping the button now shows a confirmation (check + haptic) and automatically returns to the main screen — so a lingering screen can't be tapped again by accident
• Fixed a rare issue where a tap on the watch could be recorded twice
• If you use multiple devices, please update the app on all of them
[ja]
Apple Watchの使い勝手を磨いた小さなアップデートです。
• コンプリケーションから開くアクション画面でボタンを押すと、受付表示(チェック+振動)のあと自動的にメイン画面へ戻ります — 画面が開いたままの誤タップを防ぎます
• Watchでのタップがごくまれに2回記録される問題を修正しました
• 複数のデバイスをお使いの場合は、すべてのデバイスでアップデートしてください

View File

@ -20,6 +20,10 @@ nonisolated enum WatchSync {
static let actionIDKey = "actionID"
/// (, since 1970)
static let sentAtKey = "sentAt"
/// 1 ID (1.5.1) sendMessage
/// . iPhone ID
/// ( +2 )
static let commandIDKey = "commandID"
///
static let runCommand = "run"
static let refreshCommand = "refresh"