Apple Watch companion: race countdown haptics#60
Conversation
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.
|
Warning Review limit reached
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 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 configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
WalkthroughThis 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. ChangesApple Watch Integration
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (15)
app/HDZap/HDZapApp.swiftapp/HDZap/Models/WatchHapticsDefaults.swiftapp/HDZap/Services/WatchBridge.swiftapp/HDZap/Views/Settings/SettingsView.swiftapp/HDZap/Views/Settings/WatchSettingsView.swiftapp/HDZap/Views/TimerView.swiftapp/HDZapShared/RaceSnapshot.swiftapp/HDZapWatch/HDZapWatchApp.swiftapp/HDZapWatch/Info.plistapp/HDZapWatch/Services/HapticScheduler.swiftapp/HDZapWatch/Services/RaceCoordinator.swiftapp/HDZapWatch/Services/WatchSessionReceiver.swiftapp/HDZapWatch/Services/WorkoutKeepalive.swiftapp/HDZapWatch/Views/RaceFaceView.swiftapp/project.yml
- 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
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
app/HDZap.xcodeproj/project.pbxprojapp/HDZap/Views/TimerView.swift
🚧 Files skipped from review as they are similar to previous changes (1)
- app/HDZap/Views/TimerView.swift
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.
There was a problem hiding this comment.
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
📒 Files selected for processing (13)
app/HDZap.xcodeproj/project.pbxprojapp/HDZap/HDZap.entitlementsapp/HDZap/Localizable.xcstringsapp/HDZap/Services/WatchBridge.swiftapp/HDZap/Views/Settings/WatchSettingsView.swiftapp/HDZapShared/RaceSnapshot.swiftapp/HDZapWatch/HDZapWatch.entitlementsapp/HDZapWatch/Services/HapticScheduler.swiftapp/HDZapWatch/Services/RaceCoordinator.swiftapp/HDZapWatch/Services/WatchSessionReceiver.swiftapp/HDZapWatch/Views/RaceFaceView.swiftapp/project.ymlscripts/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
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>
There was a problem hiding this comment.
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 の
watchSummaryはwatchBridge.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
⛔ Files ignored due to path filters (1)
app/HDZapWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.pngis excluded by!**/*.png
📒 Files selected for processing (14)
app/HDZap.xcodeproj/project.pbxprojapp/HDZap/HDZapApp.swiftapp/HDZap/Services/WatchBridge.swiftapp/HDZap/Views/Settings/SettingsView.swiftapp/HDZap/Views/Settings/WatchSettingsView.swiftapp/HDZap/Views/TimerView.swiftapp/HDZapShared/RaceSnapshot.swiftapp/HDZapWatch/Assets.xcassets/AppIcon.appiconset/Contents.jsonapp/HDZapWatch/Assets.xcassets/Contents.jsonapp/HDZapWatch/Info.plistapp/HDZapWatch/Services/WatchSessionReceiver.swiftapp/HDZapWatch/Services/WorkoutKeepalive.swiftapp/project.ymlscripts/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
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>
Summary
Adds an
HDZapWatchwatchOS 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):
.directionUp.notification.failure.failure× 2 (1.2 s gap)Off by default; opt-in via Settings → App → Apple Watch.
Architecture
Snapshot, not tick. The iPhone sends a
RaceSnapshoton race-state transitions only. The watch derives the live remaining-time locally from(elapsedAtPublish, publishedAt, phase)againstDate(). 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.runningsnapshot arrived up to 20 s late on real hardware because the watch had already entered low-power state. The session isdiscardWorkout()-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 toTimer(fire:interval:repeats:)added to the main runloop in.commonmode — fires within ~50 ms of its absolute date.What changed during hardware testing
Real-device iteration produced three findings that contradicted the original design:
Multi-tap count discrimination doesn't work. Calling
WKInterfaceDevice.play(.notification)twice with a 300 ms gap (even across separateTimers) 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 distinctWKHapticTypeper mark.Subtle type differences don't survive the wrist.
.success(built-in trill) and.retry(rapid alternation) felt similar to.notificationon 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.Core Haptics is iOS/macOS only. Tried using
CHHapticEnginefor custom intensity/sharpness patterns —CoreHapticsis not in the watchOS SDK. Limited to the fixedWKHapticTypemenu.Files
Shared (
HDZapShared/):RaceSnapshot.swift— Codable state model + JSON wire helpers (now with a discriminatedDecodedenum 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 9WKHapticTypecases 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 onhapticsEnabled && phase != .ended, re-arms scheduler on every snapshot, plays test haptics for the audition UI.Services/WorkoutKeepalive.swift—HKWorkoutSessionlifecycle.Services/HapticScheduler.swift— Timer-per-mark with absolute fire dates and drift logging.Info.plist,HDZapWatch.entitlements—WKApplication, companion bundle id,workout-processingbackground mode, HealthKit usage descriptions and entitlement.Tooling:
scripts/watch-deploy.sh— one-shot dev-cycle.xcodegen → xcodebuild → devicectl installto both iPhone and watch (the second bypasses iOS's auto-propagation queue, which can take minutes). Flags:--launchto process-launch after install,--no-buildfor tight UI-tweak loops.Project regeneration
The
app/HDZap.xcodeproj/project.pbxprojis committed and reflects all targets. If you changeapp/project.yml, runcd app && xcodegen generateto regenerate. The deploy script does this automatically whenproject.ymlis newer than the pbxproj.On-device verification
All checks passed on iPhone Air + Apple Watch SE 3 (watchOS 11):
WKHapticTypecases on the wrist on tap.Known follow-ups (not in this PR)
lapCountfield).WKHapticTypefrom the "Try haptics" UI (currently the audition is one-way — feel them, then code-editmarksinHapticSchedulerto swap).🤖 Generated with Claude Code
Summary by CodeRabbit
リリースノート