ReviewOS

craft-native/craft

fix(health): a live workout can only be ended by saving it, so live heart rate for a non-workout writes a workout

#421
Closed glennmichael123 opened this 7 days ago ·
7 days ago

What happens

craft.health.startWorkout({ type }) starts a real HKWorkoutSession with an HKLiveWorkoutDataSource, which is what makes an Apple Watch sample heart rate continuously instead of every few minutes. That is the only way to get live readings, and it works.

Ending it is the problem. endWorkout is the only way out from JavaScript, and it always saves:

builder.endCollection(withEnd: end) { ... 
    builder.finishWorkout { workout, finishError in

CraftHealth already has the other half — discardActivityWorkout ends the session and calls builder.discardWorkout() (CraftHealth.swift:1015) — but it is not exposed. The JS surface is only:

startWorkout: function(options) { return craft._invoke('startHealthWorkout', options || {}); },
endWorkout:   function() { return craft._invoke('endHealthWorkout'); }

There is also no mindfulness activity type. workoutActivity accepts running, walking, hiking, cycling, swimming, rowing, elliptical, strength, hiit, flexibility, yoga and other. HKWorkoutActivityType.mindAndBody is not among them.

Why this bites

An app that wants live heart rate during something that is not a workout — a guided meditation, a breathing exercise, a recovery scan — has to choose between two wrong outcomes:

  • don't start a session, and the watch reports every few minutes, so a live card sits empty for the length of a three minute meditation unless the person opens the Heart Rate app on their watch by hand; or
  • start one, and every meditation is written into Health as a yoga workout, which inflates exercise rings and workout history with things that were not workouts.

Craft already has saveMindfulSession, so the intended shape is clearly reachable — a session for the live data, discarded at the end, with a mindful session saved instead. The only thing missing is the discard.

What would help

Either of these closes it:

  1. endWorkout({ discard: true }), or a discardWorkout() beside it, routed to the existing discardActivityWorkout.
  2. A mindfulness activity type, so the saved workout is at least honest about what it was. This is the weaker option: it still writes a workout for something that is not one.

The first is a small surface change over code that already exists.

Found on iOS 26 with an Apple Watch, driving a text-guided meditation.

6 days ago

Implemented and pushed to main (head 06b723b), not released.

New API: health.discardWorkout() / craft.health.discardWorkout(), with Health.discardWorkout on the native capability protocol. It ends the live session and calls the existing HealthKit discard path, never finishWorkout. endWorkout() retains its save behavior. Discard rejects NO_WORKOUT when idle and WORKOUT_ENDING while saving; it does not delete previously recorded health samples or promise to undo activity-ring contributions.

Both the WebKit bridge and native JavaScriptCore host expose it. Current STX bundles do not yet declare this method, so the native host installs a capability-owned method that survives older bundle namespace replacement. Disabled native Health does not advertise it. The SDK has an optional host method and supports() probe; it refuses unsupported hosts rather than falling back to saving. Android is unchanged.

Commits: dcfa35f (5 files), cfa68bd (5), 5430c64 (3), 06b723b (3). A separate merge preserves Chris’s concurrent upstream changes/release; this fix is later than v0.0.370 and is NOT in that release.

Passed: 314 renderer contracts, 218 iOS builder/page-bridge tests, typecheck, built-package verification (43), pinned formatting, release rehearsals (399), lint (0 errors / 27 existing warnings). The initial 2 native dispatch/refusal tests passed. The final 4-test native suite, including namespace replacement and background completion, has now PASSED on iPhone 17 Pro / iOS 26.5: 4 passed, 0 failed, 0 skipped. The tested CraftHealth.swift and CraftNativeScreen.swift match the pushed sources byte-for-byte. Result: /tmp/craft-421-native-accepted.xcresult. Focused remote native CI 40539589228 has also been dispatched. Full CI is 40539509212, Mobile E2E 40539508908.

Full SDK is not claimed green: latest run had 797 pass / 8 skip / 3 failures in unchanged simulator-recorder and Android subprocess timeout tests. No assertions or timeouts were relaxed. A real Apple Watch/live HealthKit collection was not exercised; the tests cover bridge routing and refusal/ownership behavior, not watch sample cadence or deletion of historical Health data. Leaving open pending remote CI; local native acceptance is complete with the hardware limitations above.

5 days ago

Verified shipped in v0.0.377; closing the missing discard API issue. Use craft.health.discardWorkout() in the native host, or health.discardWorkout() through the SDK on its supported host bridge. endWorkout() still saves.

Exact release source 52779dd includes all four implementation commits. Full CI and Mobile E2E are green: https://github.com/craft-native/craft/actions/runs/40645357817 https://github.com/craft-native/craft/actions/runs/40645357872 Publication completed: https://github.com/craft-native/craft/actions/runs/40645357883 Release: https://github.com/craft-native/craft/releases/tag/v0.0.377

The release-source native log confirms all four NativeHealthDiscardUnitTests passed (background completion, disabled/missing-workout refusal, namespace replacement, repeated idle refusal), within 284 passing native unit tests and 8 passing renderer UI tests. Independently downloaded both @craft-native/ios@0.0.377 and craft-native@0.0.377, verified registry SHA512 integrity, and confirmed their host templates match the released source byte-for-byte. Also executed the published SDK wrapper against an injected host: discard routes once without saving and an unsupported host rejects without falling back to endWorkout.

Scope remains explicit: actual Apple Watch live collection was not exercised. Discard uses the existing HealthKit discard path; it does not erase previously collected health samples or promise to undo activity-ring contributions. No new release/tag was created.

Sign in to comment on this issue.