Skip to content

Commit 0d5214e

Browse files
Improve type coverage by generating types (#337)
* feat(types): improve type coverage by generating types * chore: nuke typedMetadata and type existing metadata instead * fix(types): tighten metadata typing surface * feat: nuked metadataX and improve types * chore: update changeset * feat: add healthkit contract harness * feat: add zod support and more tests * refactor: scope healthkit contracts to example app * fix: tap applesimutils in CI * fix: let contract runner boot simulator in CI * fix: filter ios CI and tighten typed bindings * fix: filter ios CI and tighten typed bindings * fix: restore expo prebuild in ios CI * fix: secure preview workflow and widen ios build filter --------- Co-authored-by: Robert Herber <robert@kingstinct.com>
1 parent b1594bf commit 0d5214e

73 files changed

Lines changed: 10228 additions & 1946 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@kingstinct/react-native-healthkit': major
3+
---
4+
5+
Make typed `metadata` the canonical metadata API and generate more of the HealthKit type surface from Apple’s SDK.
6+
7+
This release introduces generated identifier/value/unit metadata, typed metadata payloads on returned models, generic quantity sample typing, and SDK-backed schema verification to keep the surfaced API aligned with the pinned Xcode HealthKit SDK.
8+
9+
Breaking changes:
10+
11+
- Remove the legacy flattened `metadataX` fields from returned models.
12+
- Make `metadata` the single canonical metadata surface.
13+
14+
Migration examples:
15+
16+
- `sample.metadataExternalUUID` -> `sample.metadata.HKExternalUUID`
17+
- `sample.metadataWeatherCondition` -> `sample.metadata.HKWeatherCondition`
18+
- `workout.metadataAverageMETs` -> `workout.metadata.HKAverageMETs`
19+
- `categorySample.metadataMenstrualCycleStart` -> `categorySample.metadata.HKMenstrualCycleStart`
20+
21+
This is intended to make the library easier to extend over time: Apple SDK metadata flows into the generated schema and typed `metadata` surface with much less hand-maintained code.

.github/workflows/package-preview.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
name: Package Preview
22

33
on:
4-
pull_request_target:
4+
pull_request:
5+
6+
permissions: {}
57

68
jobs:
79
preview:
810
runs-on: ubuntu-latest
911
steps:
1012
- uses: actions/checkout@v4
11-
with:
12-
ref: refs/pull/${{ github.event.number }}/merge
1313

1414
- uses: oven-sh/setup-bun@v2
1515
with:

.github/workflows/test.yml

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,47 @@ on:
3838

3939
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
4040
jobs:
41+
changes:
42+
runs-on: ubuntu-latest
43+
outputs:
44+
healthkit_contracts: ${{ steps.filter.outputs.healthkit_contracts }}
45+
swift_native: ${{ steps.filter.outputs.swift_native }}
46+
permissions:
47+
pull-requests: read
48+
49+
steps:
50+
- uses: actions/checkout@v4
51+
52+
- uses: dorny/paths-filter@v3
53+
id: filter
54+
with:
55+
filters: |
56+
healthkit_contracts:
57+
- '.bun-version'
58+
- 'bun.lock'
59+
- 'package.json'
60+
- 'packages/react-native-healthkit/src/**'
61+
- 'packages/react-native-healthkit/ios/**'
62+
- 'packages/react-native-healthkit/cpp/**'
63+
- 'packages/react-native-healthkit/scripts/**'
64+
- 'packages/react-native-healthkit/nitrogen/**'
65+
- 'packages/react-native-healthkit/package.json'
66+
- 'packages/react-native-healthkit/tsconfig*.json'
67+
- 'apps/example/contracts/**'
68+
- 'apps/example/scripts/run-healthkit-contracts.sh'
69+
- 'apps/example/app.json'
70+
- 'apps/example/app/contracts.tsx'
71+
- 'apps/example/app/_layout.tsx'
72+
- 'apps/example/app/auth.tsx'
73+
- 'apps/example/constants/AllUsedIdentifiersInApp.ts'
74+
- 'apps/example/ios/**'
75+
- 'apps/example/package.json'
76+
- '.github/workflows/test.yml'
77+
swift_native:
78+
- 'packages/react-native-healthkit/ios/**'
79+
- 'packages/react-native-healthkit/cpp/**'
80+
- 'apps/example/ios/**'
81+
4182
test:
4283
# The type of runner that the job will run on
4384
runs-on: ${{ inputs.os || 'ubuntu-latest' }}
@@ -95,8 +136,10 @@ jobs:
95136
run: bun run lint
96137

97138
swiftlint:
139+
needs: changes
140+
if: needs.changes.outputs.swift_native == 'true'
98141
# The type of runner that the job will run on
99-
runs-on: ${{ inputs.os || 'macos-latest' }}
142+
runs-on: macos-15
100143
timeout-minutes: 10
101144

102145
# Steps represent a sequence of tasks that will be executed as part of the job
@@ -139,9 +182,11 @@ jobs:
139182
# xcodebuild -downloadPlatform iOS
140183
# # optional but useful:
141184
build-ios:
185+
needs: changes
186+
if: needs.changes.outputs.healthkit_contracts == 'true'
142187
# Only run on macOS since we need Xcode
143188
runs-on: macos-15
144-
timeout-minutes: 50
189+
timeout-minutes: 25
145190

146191
steps:
147192
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
@@ -157,13 +202,45 @@ jobs:
157202
## Xcode 26.2 (Swift 6.2) - latest stable
158203
- run: sudo xcode-select -s /Applications/Xcode_26.2.app
159204

205+
- name: Install simulator tooling
206+
run: |
207+
brew tap wix/brew
208+
brew install applesimutils
209+
210+
- name: Verify generated HealthKit schema and bindings
211+
working-directory: packages/react-native-healthkit
212+
run: bun run check:generated
213+
160214
- run: bun run codegen
161215
working-directory: packages/react-native-healthkit
162216

163217
- name: Expo Prebuild
164218
working-directory: apps/example
165-
run: bunx expo prebuild --platform ios
219+
run: bunx expo prebuild --platform ios --non-interactive
166220

167221
- name: Build iOS project
168-
working-directory: apps/example
169-
run: bun run build-sim
222+
working-directory: apps/example/ios
223+
run: |
224+
SIMULATOR_ID="$(
225+
xcrun simctl list devices available |
226+
sed -n 's/^[[:space:]]*iPhone[^()]* (\([0-9A-F-][0-9A-F-]*\)) (.*/\1/p' |
227+
head -n 1
228+
)"
229+
if [ -z "$SIMULATOR_ID" ]; then
230+
echo "Unable to find an available iPhone simulator." >&2
231+
exit 1
232+
fi
233+
xcodebuild \
234+
-quiet \
235+
-workspace RNHealthKit.xcworkspace \
236+
-scheme RNHealthKit \
237+
-configuration Debug \
238+
-sdk iphonesimulator \
239+
-destination "id=$SIMULATOR_ID" \
240+
ONLY_ACTIVE_ARCH=YES \
241+
COMPILER_INDEX_STORE_ENABLE=NO \
242+
DEBUG_INFORMATION_FORMAT=dwarf \
243+
build
244+
245+
- name: Run HealthKit contracts
246+
run: bun run test:contracts

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ DerivedData
2727
*.ipa
2828
*.xcuserstate
2929
project.xcworkspace
30+
**/.build
31+
**/.swiftpm
3032

3133
# Android/IJ
3234
#
@@ -79,4 +81,4 @@ lib/
7981
packages/react-native-healthkit/nitrogen/generated
8082
apps/example/android
8183

82-
tsconfig.tsbuildinfo
84+
tsconfig.tsbuildinfo

apps/example/app/(tabs)/index.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
FitzpatrickSkinType,
1818
WheelchairUse,
1919
} from '@kingstinct/react-native-healthkit/types/Characteristics'
20+
import { router } from 'expo-router'
2021
import { useEffect, useMemo, useState } from 'react'
2122
import { ListItem, type ListItemProps } from '@/components/SwiftListItem'
2223
import { enumKeyLookup } from '@/utils/enumKeyLookup'
@@ -175,6 +176,15 @@ const CoreTab = () => {
175176
/>
176177
))}
177178
</Section>
179+
<Section title="Contracts">
180+
<ListItem
181+
title="Open Contract Harness"
182+
subtitle="Hidden E2E/contract screen"
183+
onPress={() => {
184+
router.push('/contracts')
185+
}}
186+
/>
187+
</Section>
178188
</List>
179189
</Host>
180190
)

apps/example/app/_layout.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import {
44
ThemeProvider,
55
} from '@react-navigation/native'
66
import { useFonts } from 'expo-font'
7-
import { Stack } from 'expo-router'
7+
import { Stack, usePathname, useRouter } from 'expo-router'
88
import { StatusBar } from 'expo-status-bar'
9+
import { useEffect } from 'react'
910
import 'react-native-reanimated'
1011

1112
import { GestureHandlerRootView } from 'react-native-gesture-handler'
13+
import { readLaunchCommand } from '@/contracts/launchCommand'
1214
import { useColorScheme } from '@/hooks/useColorScheme'
1315

1416
export const unstable_settings = {
@@ -17,10 +19,37 @@ export const unstable_settings = {
1719

1820
export default function RootLayout() {
1921
const colorScheme = useColorScheme()
22+
const pathname = usePathname()
23+
const router = useRouter()
2024
const [loaded] = useFonts({
2125
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
2226
})
2327

28+
useEffect(() => {
29+
if (!loaded) {
30+
return
31+
}
32+
33+
const launchCommand = readLaunchCommand()
34+
if (!launchCommand) {
35+
return
36+
}
37+
38+
if (
39+
launchCommand.route === 'contracts' &&
40+
pathname !== '/contracts' &&
41+
pathname !== '/auth'
42+
) {
43+
router.replace({
44+
pathname: '/contracts',
45+
params: {
46+
autorun: launchCommand.autorun,
47+
scenario: launchCommand.scenario,
48+
},
49+
})
50+
}
51+
}, [loaded, pathname, router])
52+
2453
if (!loaded) {
2554
// Async font loading only occurs in development.
2655
return null
@@ -32,6 +61,12 @@ export default function RootLayout() {
3261
<Stack>
3362
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
3463
<Stack.Screen name="+not-found" />
64+
<Stack.Screen
65+
name="contracts"
66+
options={{
67+
title: 'HealthKit Contracts',
68+
}}
69+
/>
3570
<Stack.Screen
3671
name="auth"
3772
options={{

apps/example/app/auth.tsx

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,18 @@ import {
1717
AllObjectTypesInApp,
1818
AllSampleTypesInApp,
1919
} from '@/constants/AllUsedIdentifiersInApp'
20+
import {
21+
clearLaunchCommand,
22+
readLaunchCommand,
23+
} from '@/contracts/launchCommand'
2024
import { initSubscriptions } from '@/state/subscriptionEvents'
2125
import { enumKeyLookup } from '@/utils/enumKeyLookup'
2226

2327
const authEnumLookup = enumKeyLookup(AuthorizationRequestStatus)
2428

2529
export default function AuthScreen() {
30+
const launchCommand = readLaunchCommand()
31+
2632
const requestAuth = useCallback(async () => {
2733
try {
2834
const res = await requestAuthorization({
@@ -32,11 +38,23 @@ export default function AuthScreen() {
3238
initSubscriptions()
3339
alert(`response: ${res}`)
3440

41+
if (launchCommand?.route === 'contracts') {
42+
clearLaunchCommand()
43+
router.replace({
44+
pathname: '/contracts',
45+
params: {
46+
autorun: launchCommand.autorun,
47+
scenario: launchCommand.scenario,
48+
},
49+
})
50+
return
51+
}
52+
3553
router.replace('/')
3654
} catch (error) {
3755
console.error('Error requesting authorization:', error)
3856
}
39-
}, [])
57+
}, [launchCommand])
4058

4159
const [status, setStatus] = useState<AuthorizationRequestStatus | null>(null)
4260

@@ -63,6 +81,29 @@ export default function AuthScreen() {
6381
updateStatus()
6482
}, [])
6583

84+
useEffect(() => {
85+
if (status === AuthorizationRequestStatus.unnecessary) {
86+
if (launchCommand?.route === 'contracts') {
87+
clearLaunchCommand()
88+
router.replace({
89+
pathname: '/contracts',
90+
params: {
91+
autorun: launchCommand.autorun,
92+
scenario: launchCommand.scenario,
93+
},
94+
})
95+
}
96+
return
97+
}
98+
99+
if (
100+
status === AuthorizationRequestStatus.shouldRequest &&
101+
launchCommand?.route === 'contracts'
102+
) {
103+
void requestAuth()
104+
}
105+
}, [launchCommand, requestAuth, status])
106+
66107
return (
67108
<Host style={{ paddingTop: 40 }}>
68109
<VStack>

0 commit comments

Comments
 (0)