Skip to content

Apple Watch companion: race countdown haptics#60

Open
Saqoosha wants to merge 9 commits into
developfrom
claude/apple-watch-haptic-notifications-YDRcb
Open

Apple Watch companion: race countdown haptics#60
Saqoosha wants to merge 9 commits into
developfrom
claude/apple-watch-haptic-notifications-YDRcb

Conversation

@Saqoosha
Copy link
Copy Markdown
Owner

@Saqoosha Saqoosha commented May 14, 2026

Summary

Adds an HDZapWatch watchOS companion app that buzzes the wrist at race-countdown milestones, tuned on real hardware (Apple Watch SE 3 + iPhone Air, watchOS 11).

Final haptic mapping (different from the original spec — see "What changed during hardware testing" below):

Trigger Type Feel
30 s remaining .directionUp Single tap, subtle heads-up
20 s remaining .notification Single firm tap, more attention
10 s remaining .failure ~1.5 s alarm, urgent
0 s (buzzer) .failure × 2 (1.2 s gap) Double alarm, race over

Off by default; opt-in via Settings → App → Apple Watch.

Architecture

iPhone                                              Apple Watch
──────                                              ───────────
TimerView ──onChange──▶ WatchBridge ──WCSession──▶ WatchSessionReceiver
                            │                              │
                            │ updateApplicationContext +   │
                            │ sendMessage (when reachable) │
                            ▼                              ▼
                       lastSnapshot                  RaceCoordinator
                                                       │      │
                                                       ▼      ▼
                                              HapticScheduler  WorkoutKeepalive
                                              (Timer per mark, (HKWorkoutSession,
                                               drift logging)   .other / .indoor)

Snapshot, not tick. The iPhone sends a RaceSnapshot on race-state transitions only. The watch derives the live remaining-time locally from (elapsedAtPublish, publishedAt, phase) against Date(). A momentary WCSession outage is invisible — the watch already has all the deadlines.

Workout keepalive starts proactively. The moment the operator toggles haptics on in iPhone Settings, the watch starts an HKWorkoutSession (.other / .indoor, no sample collection) and holds it across pre-race idle, running, and paused phases. Without this, the .running snapshot arrived up to 20 s late on real hardware because the watch had already entered low-power state. The session is discardWorkout()-ed on end so it doesn't litter the Health log.

Haptic scheduling: Timer, not Task.sleep. Task.sleep(nanoseconds:) drifted 3-5 s late on the first mark on real hardware. Switched to Timer(fire:interval:repeats:) added to the main runloop in .common mode — fires within ~50 ms of its absolute date.

What changed during hardware testing

Real-device iteration produced three findings that contradicted the original design:

  1. Multi-tap count discrimination doesn't work. Calling WKInterfaceDevice.play(.notification) twice with a 300 ms gap (even across separate Timers) registers as one tap — the watch's haptic engine coalesces same-type calls within ~300 ms. So "30 s = 1 tap, 20 s = 2 taps, 10 s = 3 taps" felt like "30 s = 1, 20 s = 1, 10 s = 2." Pivoted to one distinct WKHapticType per mark.

  2. Subtle type differences don't survive the wrist. .success (built-in trill) and .retry (rapid alternation) felt similar to .notification on Apple Watch SE 3 — the operator could not reliably tell them apart. Pivoted to using duration (single tap vs .failure's ~1.5 s alarm) as the primary discriminator. Only .failure × 2 at the 0 s mark uses same-type chaining, because its ~1.5 s duration is wider than the coalescing window.

  3. Core Haptics is iOS/macOS only. Tried using CHHapticEngine for custom intensity/sharpness patterns — CoreHaptics is not in the watchOS SDK. Limited to the fixed WKHapticType menu.

Files

Shared (HDZapShared/):

  • RaceSnapshot.swift — Codable state model + JSON wire helpers (now with a discriminated Decoded enum supporting both snapshots and test-haptic messages).

iOS:

  • Services/WatchBridge.swift — owns WCSession, exposes pairing/reachability, publish(_:) for snapshots, sendTestHaptic(typeName:) for the Settings audition UI.
  • Models/WatchHapticsDefaults.swift — toggle key + default.
  • Views/Settings/WatchSettingsView.swift — main toggle, pairing/install/reachable status, and a "Try haptics" section listing all 9 WKHapticType cases for on-wrist auditioning.
  • HDZapApp.swift, TimerView.swift, Settings/SettingsView.swift — wiring.
  • HDZap.entitlements — auto-added by Xcode during the first signed build that included the watch dependency.

watchOS (HDZapWatch/):

  • HDZapWatchApp.swift, Views/RaceFaceView.swift — single-screen face. Big seconds positioned at screen center via GeometryReader-based asymmetric safe-area compensation (status bar is at top only).
  • Services/WatchSessionReceiver.swift — routes inbound snapshots and test-haptic requests via typed handlers.
  • Services/RaceCoordinator.swift — gates workout start/stop on hapticsEnabled && phase != .ended, re-arms scheduler on every snapshot, plays test haptics for the audition UI.
  • Services/WorkoutKeepalive.swiftHKWorkoutSession lifecycle.
  • Services/HapticScheduler.swift — Timer-per-mark with absolute fire dates and drift logging.
  • Info.plist, HDZapWatch.entitlementsWKApplication, companion bundle id, workout-processing background mode, HealthKit usage descriptions and entitlement.

Tooling:

  • scripts/watch-deploy.sh — one-shot dev-cycle. xcodegen → xcodebuild → devicectl install to both iPhone and watch (the second bypasses iOS's auto-propagation queue, which can take minutes). Flags: --launch to process-launch after install, --no-build for tight UI-tweak loops.

Project regeneration

The app/HDZap.xcodeproj/project.pbxproj is committed and reflects all targets. If you change app/project.yml, run cd app && xcodegen generate to regenerate. The deploy script does this automatically when project.yml is newer than the pbxproj.

On-device verification

All checks passed on iPhone Air + Apple Watch SE 3 (watchOS 11):

  • Both schemes build clean.
  • Watch app installs and launches; HealthKit auth prompt appears at first watch app open and copy reads honestly.
  • Settings "Apple Watch" row reports correct pairing/install/reachable status.
  • Race start → countdown begins ticking immediately on the watch (workout keepalive was proactive).
  • Each of the 4 marks fires within ~250 ms of its target time (Timer-based dispatch, drift logged via os.Logger).
  • 30 s, 20 s, 10 s, 0 s all feel distinct from each other.
  • Digits visually centered on screen.
  • "Try haptics" Settings section plays each of the 9 WKHapticType cases on the wrist on tap.
  • BLE goggle path unaffected (no new BLE traffic, watch path is independent).

Known follow-ups (not in this PR)

  • Watch app icon set.
  • Optional: per-lap tap (cheap to add via the existing lapCount field).
  • Optional: user-pickable per-mark WKHapticType from the "Try haptics" UI (currently the audition is one-way — feel them, then code-edit marks in HapticScheduler to swap).

🤖 Generated with Claude Code

Summary by CodeRabbit

リリースノート

  • New Features
    • Apple Watch対応を追加。iPhoneとペアリングされたApple Watchでレースカウントダウンの状態をリアルタイムで表示
    • Apple Watch上でハプティクス(振動フィードバック)によるカウントダウンアラート機能(プレミアム機能)
    • 設定画面でApple Watch接続状況の確認とハプティクス設定が可能に
    • テストハプティクス送信機能をWatch設定に追加

Review Change Stack

Adds an HDZapWatch target plus an HDZapShared sources folder.
The iPhone publishes a small RaceSnapshot over WatchConnectivity on
race-state transitions; the watch derives the live remaining time
locally and fires haptic patterns at 30 s (1 tap), 20 s (2 taps),
10 s (3 taps), and a long .failure burst at the buzzer.

A foreground-equivalent runtime is held with HKWorkoutSession
(activityType=.other, .indoor) so the haptics fire reliably with the
wrist down. The session is discarded on end so it doesn't litter the
user's Health log.

Wiring:
- WatchBridge (iOS) owns WCSession, exposes pairing/reachability,
  and publishes via updateApplicationContext + sendMessage.
- TimerView publishes on isRunning, lapCount, sessionEnded,
  raceSessionLimit, targetLapCount, and toggle changes.
- SettingsView gets an "Apple Watch" row + WatchSettingsView for the
  toggle and pairing/install/reachable status.

Note for the next person opening the project: project.yml gained the
HDZapWatch + HDZapShared targets. Run `cd app && xcodegen generate`
before opening in Xcode — this commit doesn't include the regenerated
.pbxproj because the dev environment that wrote it has no xcodegen.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 14, 2026

Warning

Review limit reached

@Saqoosha, we couldn't start this review because you've used your available PR reviews for now.

Your plan currently allows 1 review/hour. Refill in 27 minutes and 1 second.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more review capacity refills, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than trial, open-source, and free plans. In all cases, review capacity refills continuously over time.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0cb84519-e0c3-4775-afdd-2a104bd661ae

📥 Commits

Reviewing files that changed from the base of the PR and between 76accc9 and 8290b25.

📒 Files selected for processing (1)
  • app/HDZapWatch/Services/WorkoutKeepalive.swift

Walkthrough

This PR adds complete Apple Watch integration to the HDZap race-timing app. It introduces a shared race-state model for iPhone-to-Watch communication, an iPhone-side WatchConnectivity bridge that publishes race snapshots, a watch app that coordinates state and schedules countdown haptics, and wiring/settings on the iPhone side to control watch features and monitor pairing status.

Changes

Apple Watch Integration

Layer / File(s) Summary
Shared race snapshot and wire format
app/HDZapShared/RaceSnapshot.swift
RaceSnapshot value type with phase, timing, and schema versioning; RaceSnapshotWire codec for JSON encode/decode to/from WCSession dictionary keys.
iPhone WatchConnectivity bridge
app/HDZap/Services/WatchBridge.swift
WatchBridge manages WCSession lifecycle, caches race snapshots, tracks paired/installed/reachable state, publishes via applicationContext and low-latency message, re-sends on activation and reachability changes.
iPhone app setup and watch defaults
app/HDZap/HDZapApp.swift, app/HDZap/Models/WatchHapticsDefaults.swift
WatchHapticsDefaults centralizes haptics AppStorage key and default; HDZapApp injects WatchBridge and registers defaults.
iPhone settings UI and watch configuration
app/HDZap/Views/Settings/SettingsView.swift, app/HDZap/Views/Settings/WatchSettingsView.swift
SettingsView adds watch status summary link; WatchSettingsView provides haptics toggle, status display, arming hints, and test haptics buttons gated by Premium subscription and reachability.
iPhone timer watch snapshot publishing
app/HDZap/Views/TimerView.swift
TimerView publishes race snapshots to watch via PublishWatchSnapshotModifier on state changes, derives snapshot phase from race state, disables watch haptics on subscription loss.
Watch app entry point and race coordinator
app/HDZapWatch/HDZapWatchApp.swift, app/HDZapWatch/Services/RaceCoordinator.swift
RaceCoordinator aggregates WorkoutKeepalive, HapticScheduler, and WatchSessionReceiver, exposes snapshot/isWorkoutActive/workoutError, gates workout lifecycle based on phase and haptics flag.
Watch WCSession receiver and message decoding
app/HDZapWatch/Services/WatchSessionReceiver.swift
Activates WCSession, decodes incoming snapshots with schema validation, forwards to coordinator callbacks via main-actor Task, handles test-haptic messages.
Watch HealthKit workout keepalive service
app/HDZapWatch/Services/WorkoutKeepalive.swift
Manages HKWorkoutSession lifecycle to maintain background activity during active race, handles authorization, logs errors, updates isActive/lastError state.
Watch haptic countdown scheduling service
app/HDZapWatch/Services/HapticScheduler.swift
Computes countdown marks from snapshot deadline, schedules Timer taps with repeats/gaps, plays WKHapticType patterns on fire, re-arms on snapshot updates.
Watch race-face display
app/HDZapWatch/Views/RaceFaceView.swift
Displays phase label, live remaining time with 4 Hz updates, lap progress, and ARMED/error status; primes HealthKit authorization on appear.
Watch app configuration and entitlements
app/HDZapWatch/Info.plist, app/HDZapWatch/HDZapWatch.entitlements, app/HDZapWatch/Assets.xcassets/*
Info.plist declares WKApplication, companion bundle ID, workout-processing background mode, HealthKit usage; entitlements enable HealthKit developer capability; asset catalogs provide icon structure.
Xcode project and build configuration
app/project.yml, app/HDZap.xcodeproj/project.pbxproj
project.yml adds watchOS 11.0 deployment target and embeds HDZapWatch; Xcode project wires native watch target, Embed Watch Content build phase, file groups, source/resource phases, and debug/release configurations.
Watch app deployment script and tooling
scripts/watch-deploy.sh
Detects paired iPhone and Apple Watch, builds iOS app with embedded watch app, resolves and installs watch app, retries with error tolerance, optionally launches the app.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰✨ Watch apps now tick with haptic delight,
From iPhone's bridge to wrist-face bright,
Race snapshots sync through HealthKit's dance,
While countdown haptics beat romance,
One app to rule them—paired, complete! 🎯⌚

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.84% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PRタイトル「Apple Watch companion: race countdown haptics」は、watchOS向けのハプティクス機能追加という主要な変更を簡潔に説明しており、変更内容を明確に表現しています。
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/apple-watch-haptic-notifications-YDRcb

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Saqoosha Saqoosha marked this pull request as ready for review May 14, 2026 08:25
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
app/HDZapWatch/Services/RaceCoordinator.swift (1)

26-29: 💤 Low value

コメントの記述を実装に合わせて更新

26-28行目のコメントで「unowned closure capture」と記載されていますが、29行目の実装では[weak self]を使用しています。コード自体は正しく動作しますが、コメントを実装に合わせて修正することを推奨します。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/HDZapWatch/Services/RaceCoordinator.swift` around lines 26 - 29,
コメントが実装と食い違っているので、RaceCoordinator.swift の receiver 初期化コメントを更新してください: 現状の
WatchSessionReceiver クロージャは [weak self] を使用しているので、26-28行目の「unowned closure
capture」といった表現を「weak closure capture」または「[weak self]
を使用して初期化」など実際の実装を正確に説明する文に置き換え、WatchSessionReceiver を参照する理由(init 完了まで遅延するため)と
weak キャプチャであることが明確に記載されるようにしてください。
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/project.yml`:
- Around line 63-67: The PR temporarily clears
ASSETCATALOG_COMPILER_APPICON_NAME which allows local builds but will cause App
Store/TestFlight rejection due to missing watchOS app icons; update the watchOS
asset catalog and project.yml so ASSETCATALOG_COMPILER_APPICON_NAME points to a
valid watch app icon set (including required sizes like AppIcon40x40@2x,
AppIcon44x44@2x, AppIcon86x86@2x) for the watch target (TARGETED_DEVICE_FAMILY:
"4"), or if icons cannot be provided now convert this change into a tracked
task/issue and restore a blocking value instead of an empty string so the PR
cannot be merged until assets are added.

---

Nitpick comments:
In `@app/HDZapWatch/Services/RaceCoordinator.swift`:
- Around line 26-29: コメントが実装と食い違っているので、RaceCoordinator.swift の receiver
初期化コメントを更新してください: 現状の WatchSessionReceiver クロージャは [weak self]
を使用しているので、26-28行目の「unowned closure capture」といった表現を「weak closure
capture」または「[weak self] を使用して初期化」など実際の実装を正確に説明する文に置き換え、WatchSessionReceiver
を参照する理由(init 完了まで遅延するため)と weak キャプチャであることが明確に記載されるようにしてください。
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: beb560c9-772e-4036-a9ba-f8fa42260ca4

📥 Commits

Reviewing files that changed from the base of the PR and between 2ebf207 and 7b402d2.

📒 Files selected for processing (15)
  • app/HDZap/HDZapApp.swift
  • app/HDZap/Models/WatchHapticsDefaults.swift
  • app/HDZap/Services/WatchBridge.swift
  • app/HDZap/Views/Settings/SettingsView.swift
  • app/HDZap/Views/Settings/WatchSettingsView.swift
  • app/HDZap/Views/TimerView.swift
  • app/HDZapShared/RaceSnapshot.swift
  • app/HDZapWatch/HDZapWatchApp.swift
  • app/HDZapWatch/Info.plist
  • app/HDZapWatch/Services/HapticScheduler.swift
  • app/HDZapWatch/Services/RaceCoordinator.swift
  • app/HDZapWatch/Services/WatchSessionReceiver.swift
  • app/HDZapWatch/Services/WorkoutKeepalive.swift
  • app/HDZapWatch/Views/RaceFaceView.swift
  • app/project.yml

Comment thread app/project.yml Outdated
- TimerView: extract six .onChange triggers + .onAppear into a
  PublishWatchSnapshotModifier. Inlined into body they tripped the
  Swift type-checker's "unable to type-check this expression in
  reasonable time" — each modifier compounded the inferred generic
  type.
- HDZap.xcodeproj: regenerate via xcodegen so the watch target,
  HDZapShared sources, and new iOS files are registered. Pre-existing
  files inherit fresh UUIDs from xcodegen's deterministic naming;
  diff is purely structural.

Verified locally (Xcode 26.5):
  xcodebuild -scheme HDZap     -destination 'generic/platform=iOS'    build  → SUCCEEDED
  xcodebuild -scheme HDZapWatch -destination 'generic/platform=watchOS' build → SUCCEEDED
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/HDZap.xcodeproj/project.pbxproj`:
- Around line 298-314: The HDZapWatch PBXNativeTarget is missing a Resources
build phase and app icon configuration; update project.yml to add a Resources
build phase (include the Watch asset catalog reference, e.g., Assets.xcassets
with an AppIcon set) to the HDZapWatch target and set buildSettings for that
target to ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon, then re-run xcodegen
generate so the generated project adds a Resources entry under
HDZapWatch.buildPhases and includes the Assets.xcassets reference so the Watch
app icon is compiled.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 38d222cc-3930-4dad-8f0c-b81eafeeaebe

📥 Commits

Reviewing files that changed from the base of the PR and between 7b402d2 and 43c85d9.

📒 Files selected for processing (2)
  • app/HDZap.xcodeproj/project.pbxproj
  • app/HDZap/Views/TimerView.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/HDZap/Views/TimerView.swift

Comment thread app/HDZap.xcodeproj/project.pbxproj
Saqoosha added 3 commits May 14, 2026 21:47
The watch app's HKWorkoutSession was failing to install on a paired
Apple Watch with "This app could not be installed at this time." The
bundle was signed correctly but the iOS-side install gate rejected it
because the embedded.mobileprovision had no `com.apple.developer.healthkit`
entitlement and the wildcard team profile can't carry capabilities.

- Add app/HDZapWatch/HDZapWatch.entitlements declaring HealthKit.
- Wire it into project.yml so xcodegen attaches it to the watch target
  and the iOS app target stays unaffected.

Note: building with this entitlement requires a specific App ID for
`sh.saqoo.HDZap.watchkitapp` with HealthKit capability enabled on
developer.apple.com. Xcode's GUI ('Run' button) does that auto-
registration via `-allowProvisioningUpdates`; if running from the CLI,
make sure the signing account has access to the team that owns the
App ID, otherwise the build fails with "No Account for Team ...".
… test UI

Tuned on real hardware (Apple Watch SE 3 + iPhone Air, watchOS 11):

- Replaced Task.sleep-based haptic dispatch with Timer.scheduledTimer.
  Task.sleep drifted 3-5 s late on the first scheduled mark even with
  an active HKWorkoutSession; Timer in .common runloop mode fires
  within ~50 ms.
- Workout keepalive now starts proactively the moment the operator
  toggles haptics on, not on the .running snapshot — earlier flow
  left the watch in low-power state during the pre-race window and
  the .running message landed up to 20 s late.
- Dropped multi-tap count-based discrimination. The watch coalesces
  same-type WKHapticType calls within ~300 ms even across separate
  Timers, so .notification x 2 felt single and x 3 felt double on
  the wrist. Each mark now uses a distinct type instead:
    30 s  .directionUp   (single tap, subtle heads-up)
    20 s  .notification  (single firm tap, more attention)
    10 s  .failure       (~1.5 s alarm, urgent)
    0 s   .failure x 2   (1.2 s gap, double alarm — race over)
  Same-type chaining only works for `.failure` at 0 s because its
  ~1.5 s duration is wider than the coalescing window.
- Verified Core Haptics is iOS/macOS-only — not available on watchOS,
  so the design space is limited to WKHapticType + sequencing.

Vertical centering on the watch face: position the seconds readout
via GeometryReader against the asymmetric safe-area inset (status
bar at top, none at bottom). Using a quarter of the top inset rather
than half lands the visual midpoint on the screen center — the
glyph's empty descender space accounts for the rest of the offset.

iPhone Settings "Try haptics" subsection: list all nine built-in
WKHapticType cases with one-line blurbs. Tapping a row sends a
sendMessage to the watch (RaceSnapshotWire.testHapticKey wire format)
and the watch plays it once. Lets the operator audition each pattern
on their wrist before locking it into a race mark.

scripts/watch-deploy.sh: one-shot dev-cycle script. Regenerates the
Xcode project if project.yml is newer than the pbxproj, builds the
HDZap scheme with provisioning auto-update, then pushes the .app to
both the paired iPhone and the watch via devicectl in two install
calls (the second to the watch bypasses iOS's auto-propagation queue,
which can take minutes to hours otherwise). Accepts --launch to also
process-launch on the iPhone after install, and --no-build to skip
xcodebuild and reuse DerivedData for tight UI-tweak loops.

The iOS HDZap.entitlements file was auto-added by Xcode during the
first signed build that included the watch dependency. Kept as-is
even though the iPhone target doesn't directly use HealthKit — the
auto-signing flow regenerated provisioning around it and the build
matches the device's installed entitlements after Xcode's first Run.
The watch target was previously building without an AppIcon set
(ASSETCATALOG_COMPILER_APPICON_NAME explicitly cleared in project.yml
to keep the build green pre-asset-catalog). Required for App Store
submission, and gives the operator an actual logo to tap from the
watch app drawer instead of the gray placeholder.

- Create app/HDZapWatch/Assets.xcassets/AppIcon.appiconset/ containing
  the same 1024x1024 source PNG as the iOS target, with Contents.json
  tagged platform=watchos. Single-image asset; watchOS 10+ generates
  the runtime sizes from the source.
- Flip ASSETCATALOG_COMPILER_APPICON_NAME from "" back to "AppIcon"
  on the watch target so the compiled Assets.car carries the icon.

Verified: HDZap.app/Watch/HDZapWatch.app now contains Assets.car and
Info.plist's CFBundlePrimaryIcon.CFBundleIconName = AppIcon.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/HDZap/Views/Settings/WatchSettingsView.swift`:
- Line 46: Update the user-facing haptics description in WatchSettingsView to
match the implemented behavior: edit the Text in WatchSettingsView (the string
on line with the current description) so it explains that each mark uses a
different haptic type and that at the buzzer (0s) the .failure haptic is played
twice (long tap represented by two failure taps), rather than saying 30s single
/ 20s double / 10s triple; ensure the wording clearly maps marks -> distinct
haptic types and explicitly mentions the double .failure at 0s.

In `@scripts/watch-deploy.sh`:
- Line 47: The AWK field-checking regex only matches uppercase hex so lowercase
UUIDs are missed; update the pattern used in the loop that inspects $i (for (i =
1; i <= NF; i++) if ($i ~ /^[0-9A-F]{8}-/) ...) to accept lowercase hex as well
(e.g. use [0-9A-Fa-f] or enable IGNORECASE) so both upper- and lowercase UUIDs
from devicectl are detected and printed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 59568136-e013-456c-b655-dc00466fc172

📥 Commits

Reviewing files that changed from the base of the PR and between 43c85d9 and 542ad0f.

📒 Files selected for processing (13)
  • app/HDZap.xcodeproj/project.pbxproj
  • app/HDZap/HDZap.entitlements
  • app/HDZap/Localizable.xcstrings
  • app/HDZap/Services/WatchBridge.swift
  • app/HDZap/Views/Settings/WatchSettingsView.swift
  • app/HDZapShared/RaceSnapshot.swift
  • app/HDZapWatch/HDZapWatch.entitlements
  • app/HDZapWatch/Services/HapticScheduler.swift
  • app/HDZapWatch/Services/RaceCoordinator.swift
  • app/HDZapWatch/Services/WatchSessionReceiver.swift
  • app/HDZapWatch/Views/RaceFaceView.swift
  • app/project.yml
  • scripts/watch-deploy.sh
🚧 Files skipped from review as they are similar to previous changes (4)
  • app/HDZapWatch/Services/WatchSessionReceiver.swift
  • app/project.yml
  • app/HDZap/Services/WatchBridge.swift
  • app/HDZapWatch/Views/RaceFaceView.swift

Comment thread app/HDZap/Views/Settings/WatchSettingsView.swift Outdated
Comment thread scripts/watch-deploy.sh Outdated
Saqoosha and others added 3 commits May 15, 2026 00:23
Six review agents found a handful of real issues across the watch
companion that are now fixed:

- Drop the orphaned app/HDZap/HDZap.entitlements that auto-installed
  during Xcode's first signed build but was never wired into the
  iPhone target's CODE_SIGN_ENTITLEMENTS. Declaring HealthKit on the
  iOS app while never importing HealthKit (or shipping a usage
  string) is an App Store Guideline 5.1.5 rejection risk.
- WorkoutKeepalive: clear session/builder/isActive synchronously in
  stop() so a rapid stop()→start() (multi-heat tournament loop) can't
  trip the `guard session == nil` while the prior endCollection's
  completion is still pending. Capture the outgoing references
  locally so the async teardown still runs.
- WorkoutKeepalive: only flip isActive=true from inside
  beginCollection's completion. The previous unconditional set made
  the UI claim "ARMED" before HealthKit had actually started
  collecting, masking the exact failure mode the keepalive exists to
  prevent. Surface beginCollection failures to lastError and roll
  back the session on failure.
- WorkoutKeepalive: condition discardWorkout() on endCollection
  succeeding — discarding after a failed end is undefined behavior
  per Apple's HK lifecycle docs.
- WorkoutKeepalive: clear lastError on a successful auth /
  beginCollection so a one-time transient failure doesn't keep the
  Settings status red after the user has since fixed it.
- Mark WorkoutKeepalive @observable so SwiftUI's dependency tracker
  picks up isActive / lastError reads through RaceCoordinator's
  computed-property forwards. The "ARMED" pip was redrawing only
  by accident via the 4 Hz cosmetic tick on RaceFaceView.
- WatchSettingsView footer: drop the fossil count-based description
  ("30 s single, 20 s double, 10 s triple") and describe the actual
  per-mark patterns the scheduler plays.
- watch-deploy.sh: make the devicectl UUID regex case-insensitive
  ([0-9A-Fa-f]) so lowercase hex output from newer Xcode builds is
  still detected.
- WatchBridge: correct the "rejects an exact-equal dictionary"
  comment — WatchConnectivity *silently suppresses* delivery of an
  identical context (no throw, no callback), which is exactly why
  the publishedAt nonce matters.
- RaceSnapshot: relax the schemaVersion check from `==` to `<=` so a
  watch on a higher version still accepts older payloads (Codable
  tolerates missing keys for additive changes). Only forward-rolled
  iPhones get dropped. Docstring updated to match the new contract.
- Drop NSHealthShareUsageDescription from the watch Info.plist and
  project.yml. `requestAuthorization(toShare:read:)` is share-only
  (read array is empty), so the share usage string was dead weight
  that would have leaked into the privacy nutrition label.
Resolves conflicts against develop's StoreKit2 paywall (PR #73 + #74)
on top of the maintainer's review-feedback follow-ups for PR #60.

Subscription gate:
- TimerView.publishWatchSnapshot sends
  hapticsEnabled = (watchHapticsEnabled && subscription.isEntitled),
  so the watch only acts on what the iPhone says. Single gate — no
  parallel check on the wrist.
- PublishWatchSnapshotModifier picks up subscription.isEntitled as a
  tracked input, and the .onChange handler clears
  watchHapticsEnabled when entitlement lapses (mirrors the TTS-engine
  rollback in AudioSettingsView).
- WatchSettingsView replaces the toggle (and the Try-haptics audition
  section) with a "Subscribe to enable" paywall card when not
  entitled. PaywallView presented via .sheet.
- SettingsView's row summary shows "Premium" for non-subscribers.

Conflict resolution notes:
- project.pbxproj: took ours (xcodegen will regenerate from
  project.yml after merge).
- Localizable.xcstrings: took theirs to keep develop's full Premium
  TTS translation catalog. Watch-specific keys regenerate on next
  build.
- Pulls in PRs #75#82: paywall polish, Premium TTS countdown latency
  improvements, audio cancel/leak fixes, mixer for overlapping numbers,
  Cartesia removal, on-device TTS evaluation, per-section manual
  screenshots, iOS 26.5 capture infra, manual UX polish, PNG optimization
- project.yml: dropped the local storeKitConfiguration scheme override
  to match develop's Sandbox-by-default policy (Apple Root CA G3 can't
  verify Xcode-only signatures on the Worker side)
- project.pbxproj: took ours; xcodegen will regenerate from project.yml
- All paywall-gating wiring for Apple Watch haptics survived the
  auto-merge: TimerView.publishWatchSnapshot still ANDs
  subscription.isEntitled, PublishWatchSnapshotModifier still tracks
  the entitlement flag, WatchSettingsView still swaps the toggle for
  the paywall card

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/HDZap/Views/Settings/SettingsView.swift (1)

380-386: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

未対応端末での Watch 要約表示を分岐してください

Line 381〜Line 386 の watchSummarywatchBridge.isSupported を見ていないため、未対応端末でも "On · No watch" になり、状態が誤解されます。WatchSettingsView の表示と揃えて unsupported を先に返す分岐を入れるのが安全です。

修正例
 private var watchSummary: String {
     if !subscription.isEntitled { return String(localized: "Premium") }
+    if !watchBridge.isSupported { return String(localized: "Unsupported") }
     if !watchHapticsEnabled { return String(localized: "Off") }
     if !watchBridge.isPaired { return String(localized: "On · No watch") }
     if !watchBridge.isWatchAppInstalled { return String(localized: "On · App missing") }
     if !watchBridge.isReachable { return String(localized: "On · Not armed") }
     return String(localized: "On · Armed")
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/HDZap/Views/Settings/SettingsView.swift` around lines 380 - 386,
watchSummary currently omits checking watchBridge.isSupported which causes
unsupported devices to show "On · No watch"; update the computed var
(watchSummary) to check !watchBridge.isSupported before the
paired/installed/reachable branches and return a localized unsupported string
(e.g. String(localized: "On · Unsupported")) so its output matches
WatchSettingsView's unsupported state handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/HDZapWatch/Services/WorkoutKeepalive.swift`:
- Around line 80-113: The beginCollection completion and the
workoutSession(_:didFailWithError:) handler can act on stale session/builder
objects and should only mutate state when the session/builder from the callback
matches the current self.session/self.builder; update the
b.beginCollection(withStart:) completion to capture s and b and first check
guard self.session === s && self.builder === b before changing
lastError/isActive/session/builder or calling s.end(), and likewise modify
workoutSession(_:didFailWithError:) to compare the failing workoutSession
instance to self.session and only clear lastError/isActive/session/builder when
they are the same.

---

Outside diff comments:
In `@app/HDZap/Views/Settings/SettingsView.swift`:
- Around line 380-386: watchSummary currently omits checking
watchBridge.isSupported which causes unsupported devices to show "On · No
watch"; update the computed var (watchSummary) to check !watchBridge.isSupported
before the paired/installed/reachable branches and return a localized
unsupported string (e.g. String(localized: "On · Unsupported")) so its output
matches WatchSettingsView's unsupported state handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 30543917-e8e9-443a-8292-13415dc5e6d3

📥 Commits

Reviewing files that changed from the base of the PR and between 542ad0f and 76accc9.

⛔ Files ignored due to path filters (1)
  • app/HDZapWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png is excluded by !**/*.png
📒 Files selected for processing (14)
  • app/HDZap.xcodeproj/project.pbxproj
  • app/HDZap/HDZapApp.swift
  • app/HDZap/Services/WatchBridge.swift
  • app/HDZap/Views/Settings/SettingsView.swift
  • app/HDZap/Views/Settings/WatchSettingsView.swift
  • app/HDZap/Views/TimerView.swift
  • app/HDZapShared/RaceSnapshot.swift
  • app/HDZapWatch/Assets.xcassets/AppIcon.appiconset/Contents.json
  • app/HDZapWatch/Assets.xcassets/Contents.json
  • app/HDZapWatch/Info.plist
  • app/HDZapWatch/Services/WatchSessionReceiver.swift
  • app/HDZapWatch/Services/WorkoutKeepalive.swift
  • app/project.yml
  • scripts/watch-deploy.sh
💤 Files with no reviewable changes (1)
  • app/HDZapWatch/Info.plist
✅ Files skipped from review due to trivial changes (1)
  • app/HDZapWatch/Assets.xcassets/Contents.json

Comment thread app/HDZapWatch/Services/WorkoutKeepalive.swift
A rapid stop()→start() (e.g. multi-heat tournament loop) could land an
old session's beginCollection completion or didFailWithError after the
new session was already armed, silently overwriting the new state.

- start() beginCollection completion: guard self.session === s &&
  self.builder === b before touching lastError/isActive/session/builder.
  Outgoing-session failure paths still call s.end() so the dying
  session is torn down cleanly; current state stays untouched.
- workoutSession(_:didFailWithError:): guard self.session ===
  workoutSession before clearing state. Stale failures get logged
  at .warning and dropped.

Addresses PR #60 CodeRabbit review feedback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants