On a native screen (renderer: native), craft.health.requestAuthorization
never settles once iOS has shown the HealthKit sheet. The sheet appears, the
athlete grants it, the sheet dismisses — and the promise is still pending.
Everything awaiting it is stuck, so an Apple Health sync sits on "Syncing…"
with no result and no error, forever.
Reproduced on craft-native 0.0.160, iPhone 17 Pro simulator, iOS 26, with
enableHealthKit: true and an all-native app.
What it looks like from the app
The app calls these two, in order:
await health.requestAuthorization(READ_TYPES, { write: false })
await health.requestAuthorization(WRITE_TYPES, { write: true })Each press of "Connect Apple Health" shows exactly one sheet and then hangs:
| press | sheet shown | then |
|---|---|---|
| 1st | the read types | granted → hangs; the second call is never reached |
| 2nd | Walking + Running Distance, Workouts | granted → hangs |
| 3rd | the same two again | granted → hangs |
The third press is the tell. iOS only re-asks about a type whose authorization is still not determined, so the second request's grant is not landing — which is consistent with the caller never being told the first one finished and the pair never completing as a unit.
The simulator's own HealthKit store agrees. From
.../data/Library/Health/healthdb.sqlite (read with its -wal), the
authorization table holds 14 rows for the app: 11 at status 103 and
3 still at status 100 — and it is those three that the sheet keeps asking
about.
The likely cause: the completion comes back off the main queue
CraftHealth.swift ends every method the same way:
healthStore.requestAuthorization(toShare: shareTypes, read: readTypes) { success, error in
if success { completion(.success(true)) }
else { completion(.failure(CraftHealthError(message: error?.localizedDescription ?? "Authorization failed"))) }
}HealthKit calls that block on an arbitrary queue, and the block hands the result
straight to completion. CraftHealth.swift contains no DispatchQueue.main
at all — 0 occurrences across 37 completion( call sites. Every other service
does hop:
| file | DispatchQueue.main |
|---|---|
CraftNativeServices.swift (push, speech, …) | 10 |
CraftHealth.swift | 0 |
That asymmetry matches the symptom exactly. A native screen's JS runs in a
JSContext driven from the main thread, so a capability result arriving on a
background queue has no safe way back in; Push.register goes through the same
untimed callPersonFacingAPI path, raises its own system prompt, and resolves
fine — and it dispatches to main before completing.
I have not instrumented the Swift side to prove the result is dropped rather than lost somewhere else, so treat the mechanism as strongly indicated rather than confirmed. The asymmetry and the reproduction are solid.
Why it matters more than it used to
In a web view a result can be posted in from any thread, so this would not have
shown. With renderer: native every Health call goes through the native-screen
bridge, so Apple Health is unusable in an all-native app — the first sheet is
also the last thing that happens.
Suggested fix
Hop to the main queue before completing, in requestAuthorization and in the
other 36 completion sites — the same DispatchQueue.main.async { … } the rest
of Craft already uses. Worth a look at whether the query methods
(getWorkouts, getDailyStatistics, getHeartRateSamples, getSleepIntervals)
have the same problem; they are reached only after authorization, so they are
currently untestable from an all-native app.
Separately: a capability that can wait forever is hard on the app above it.
callPersonFacingAPI being untimed is right for a sheet a person is reading,
but a result that never arrives is indistinguishable from a person who has not
answered yet.