ReviewOS

also looking at this

craft-native/craft

fix(native): preserve workspace sources and native overlay lifecycle

#384
Merged chris wants to merge codex/windows-zig-cache-diagnostics into main
20 files +500 -227

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/android/README.mdmodified+3-0
Changes to packages/android/README.md
@@ -448,6 +448,9 @@ The generated runtime supports Android 8.0 / API 26 and newer, exported as
448448`ANDROID_MIN_SDK`. `minSdk` defaults to that floor; lower values are rejected
449449before project files are written, including when Health Connect is enabled.
450450Health Connect normalizes `compileSdk` to at least 36.
451Opting into Health Services selects `minSdk: 30` (Android 11) when omitted,
452matching its AndroidX library requirement. Explicit minimums below 30 are
453rejected before writing the project; higher supported minimums are retained.
451454The same effective `minSdk` is written to Gradle and both configuration files.
452455A custom `appIconPath` must be a regular PNG, WebP, JPG, or GIF file; its supported
453456extension is preserved in the generated drawable resource.
packages/android/src/index.test.tsmodified+30-0
Changes to packages/android/src/index.test.ts
@@ -863,6 +863,36 @@ describe('Craft Android builder', () => {
863863 finally { rmSync(root, { recursive: true, force: true }) }
864864 })
865865
866 it('selects Health Services API 30 by default and retains higher explicit minimums', async () => {
867 const root = createTempDir('craft-android-health-services-sdk-')
868 try {
869 for (const renderer of ['native', 'web'] as const) {
870 for (const minSdk of [undefined, 30, 33]) {
871 const output = join(root, renderer, String(minSdk))
872 await init({ name: 'HealthServicesDevice', output, runtimeDir: null, config: { renderer, enableHealthServices: true, minSdk } })
873 const expected = minSdk ?? 30
874 expect(JSON.parse(readFileSync(join(output, 'craft.config.json'), 'utf8')).minSdk).toBe(expected)
875 expect(JSON.parse(readFileSync(join(output, 'app/src/main/assets/craft.config.json'), 'utf8')).minSdk).toBe(expected)
876 expect(readFileSync(join(output, 'app/build.gradle.kts'), 'utf8')).toContain(`minSdk = ${expected}`)
877 }
878 }
879 }
880 finally { rmSync(root, { recursive: true, force: true }) }
881 })
882
883 it('rejects explicitly incompatible Health Services minimums before writing a project', async () => {
884 const root = createTempDir('craft-android-health-services-sdk-rejected-')
885 try {
886 for (const minSdk of [26, 29]) {
887 const output = join(root, String(minSdk))
888 await expect(init({ name: 'UnsupportedHealthServicesDevice', output, runtimeDir: null, config: { enableHealthServices: true, minSdk } }))
889 .rejects.toThrow('Android Health Services requires minSdk at least 30')
890 expect(existsSync(output)).toBe(false)
891 }
892 }
893 finally { rmSync(root, { recursive: true, force: true }) }
894 })
895
866896 it('routes every Kotlin template through the project generator', () => {
867897 const templates = readdirSync(join(import.meta.dir, '../templates'))
868898 .filter(name => name.endsWith('.kt.template'))
packages/android/src/index.tsmodified+7-2
Changes to packages/android/src/index.ts
@@ -127,6 +127,7 @@ const KOTLIN_KEYWORDS = new Set([
127127
128128/** Oldest Android API level supported by the generated Kotlin runtime. */
129129export const ANDROID_MIN_SDK = 26
130const ANDROID_HEALTH_SERVICES_MIN_SDK = 30
130131
131132export interface CraftAndroidConfig {
132133 /** Bundled font files keyed by their PostScript font name, used as native fontFamily. */
@@ -162,7 +163,7 @@ export interface CraftAndroidConfig {
162163 */
163164 enableHeartRate?: boolean
164165 enableHealthConnect?: boolean
165 /** Android Health Services live exercise tracking for native screens. */
166 /** Android Health Services live exercise tracking for native screens. Requires minSdk 30. */
166167 enableHealthServices?: boolean
167168 /** Google Wallet save-link handoff for native screens. */
168169 enableWallet?: boolean
@@ -211,7 +212,7 @@ export interface CraftAndroidConfig {
211212 mapsApiKey?: string
212213 devServerURL?: string
213214 hasBundledFallback?: boolean
214 /** Android API level, at least ANDROID_MIN_SDK (26). */
215 /** Android API level, at least ANDROID_MIN_SDK (26), or 30 with Health Services. */
215216 minSdk?: number
216217 compileSdk?: number
217218 targetSdk?: number
@@ -550,6 +551,9 @@ function validateAndroidConfig(config: CraftAndroidConfig): void {
550551 if (Number(config.minSdk) < ANDROID_MIN_SDK) {
551552 throw new Error(`Android minSdk must be at least ${ANDROID_MIN_SDK} (Android 8.0) for the Craft runtime`)
552553 }
554 if (config.enableHealthServices && Number(config.minSdk) < ANDROID_HEALTH_SERVICES_MIN_SDK) {
555 throw new Error(`Android Health Services requires minSdk at least ${ANDROID_HEALTH_SERVICES_MIN_SDK} (Android 11)`)
556 }
553557 if (Number(config.minSdk) > Number(config.targetSdk)) {
554558 throw new Error('Android minSdk must not exceed targetSdk')
555559 }
@@ -959,6 +963,7 @@ export async function init(options: InitOptions): Promise<void> {
959963 appName: name,
960964 packageName: finalPackageName,
961965 }
966 if (config.enableHealthServices && given.minSdk === undefined) config.minSdk = ANDROID_HEALTH_SERVICES_MIN_SDK
962967 if (config.enableBackgroundLocation) config.enableGeolocation = true
963968 if (config.enableHealthConnect) {
964969 config.compileSdk = Math.max(config.compileSdk ?? 36, 36)
packages/android/src/native-shell.test.tsmodified+15-0
Changes to packages/android/src/native-shell.test.ts
@@ -186,6 +186,21 @@ describe('the native shell prelude (JavaScriptSandbox has no synchronous host ca
186186})
187187
188188describe('the native app shell in a generated project', () => {
189 it('declares one location permission when live health and Bluetooth both need it', () => {
190 const permissions = renderAndroidPermissions({
191 appName: 'Live health', packageName: 'dev.craft.livehealth', renderer: 'native',
192 enableHealthServices: true, enableHeartRate: true,
193 })
194 expect(permissions.match(/android\.permission\.ACCESS_FINE_LOCATION/g)).toHaveLength(1)
195 expect(permissions).toContain('<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />')
196 expect(permissions).not.toContain('ACCESS_FINE_LOCATION" android:maxSdkVersion')
197 const bluetooth = renderAndroidPermissions({
198 appName: 'Bluetooth', packageName: 'dev.craft.bluetooth', renderer: 'native', enableHeartRate: true,
199 })
200 expect(bluetooth.match(/android\.permission\.ACCESS_FINE_LOCATION/g)).toHaveLength(1)
201 expect(bluetooth).toContain('ACCESS_FINE_LOCATION" android:maxSdkVersion="30"')
202 })
203
189204 it('declares only the Health Connect permissions the app reads and writes', () => {
190205 const permissions = healthConnectPermissions({ healthConnect: { read: ['sleep', 'workouts'], write: ['mindful'], background: true } })
191206 expect(permissions).toContain('android.permission.health.READ_SLEEP')
packages/android/templates/CraftHealthServicesService.kt.templatemodified+4-0
Changes to packages/android/templates/CraftHealthServicesService.kt.template
@@ -40,6 +40,10 @@ internal class CraftHealthServicesService : Service() {
4040 createNotificationChannel()
4141 exerciseClient = HealthServices.getClient(this).exerciseClient
4242 callback = object : ExerciseUpdateCallback {
43 override fun onRegistered() = Unit
44 override fun onRegistrationFailed(throwable: Throwable) {
45 emitError(throwable.message ?: "Health Services callback registration failed")
46 }
4347 override fun onExerciseUpdateReceived(update: ExerciseUpdate) = emit(update)
4448 override fun onAvailabilityChanged(dataType: DataType<*, *>, availability: Availability) {
4549 emitError("${dataType.name} is unavailable ($availability)")