ReviewOS

also looking at this

craft-native/craft

feat: finish native customization and platform seams

#401
Merged glennmichael123 wants to merge codex/issues-388-400-344 into main
21 files +707 -17

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/src/index.test.tsmodified+21-2
Changes to packages/android/src/index.test.ts
@@ -1,7 +1,7 @@
1import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
1import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
22import { join } from 'node:path'
33import { afterAll, describe, expect, it } from 'bun:test'
4import { ANDROID_MIN_SDK, build, init, installRuntime, renderAndroidDeepLinks, renderAndroidPermissions, resolveRuntimeDir, syncAndroidWebAssets, syncNativeFonts } from './index'
4import { ANDROID_MIN_SDK, build, init, installRuntime, renderAndroidDeepLinks, renderAndroidPermissions, resolveRuntimeDir, runPostGenerateHook, syncAndroidWebAssets, syncNativeFonts } from './index'
55import { createTempDir, removeAllTempDirs } from '../../typescript/src/temp-dir'
66
77// Every scratch directory this file made goes when the file is done (#357).
@@ -15,6 +15,25 @@ function generatedFiles(path: string): string[] {
1515}
1616
1717describe('Craft Android builder', () => {
18 it('runs a declared postGenerate command from the project root with metadata', async () => {
19 const output = createTempDir('craft-android-post-generate-')
20 await init({ name: 'Hook App', output, runtimeDir: null, config: {
21 postGenerate: { command: 'sh', args: ['-c', 'printf "%s|%s|%s" "$PWD" "$CRAFT_PLATFORM" "$CRAFT_GENERATE_PHASE" > hook.txt'] },
22 } })
23 const config = JSON.parse(readFileSync(join(output, 'craft.config.json'), 'utf8'))
24 expect(config.postGenerate).toEqual({ command: 'sh', args: ['-c', 'printf "%s|%s|%s" "$PWD" "$CRAFT_PLATFORM" "$CRAFT_GENERATE_PHASE" > hook.txt'] })
25 runPostGenerateHook(config.postGenerate, output)
26 expect(readFileSync(join(output, 'hook.txt'), 'utf8')).toBe(`${realpathSync(output)}|android|build`)
27 })
28
29 it('reports invalid Android postGenerate commands and non-zero exits', () => {
30 const output = createTempDir('craft-android-post-generate-errors-')
31 expect(() => runPostGenerateHook({ command: '' }, output)).toThrow('postGenerate.command must be a non-empty executable')
32 expect(() => runPostGenerateHook({ command: join(output, 'missing-hook') }, output)).toThrow('postGenerate hook could not start')
33 expect(() => runPostGenerateHook({ command: 'sh', args: ['-c', 'printf "patch failed" >&2; exit 7'] }, output))
34 .toThrow('postGenerate hook failed with exit code 7: patch failed')
35 })
36
1837 it('bundles custom fonts with their licenses and removes retired fonts', async () => {
1938 const root = createTempDir('craft-native-fonts-')
2039 const font = join(root, 'Signature.ttf')
packages/android/src/index.tsmodified+46-0
Changes to packages/android/src/index.ts
@@ -138,6 +138,8 @@ export interface CraftAndroidConfig {
138138 renderer?: 'web' | 'native'
139139 /** "system" follows the device; light/dark pin the generated host. */
140140 appearance?: 'light' | 'dark' | 'system'
141 /** Runs from the generated project root before Gradle compiles the app. */
142 postGenerate?: CraftPostGenerateHook
141143 version?: string
142144 versionCode?: number
143145 darkMode?: boolean
@@ -261,6 +263,12 @@ export interface CraftAndroidConfig {
261263 }
262264}
263265
266/** A declared command Craft runs after the native project has been generated. */
267export interface CraftPostGenerateHook {
268 command: string
269 args?: string[]
270}
271
264272/** The shared health data types (the iOS builder's HealthKit names). */
265273export type HealthConnectType = 'steps' | 'heartRate' | 'activeEnergy' | 'distance' | 'workouts' | 'restingHeartRate'
266274 | 'heartRateVariability' | 'bodyMass' | 'sleep' | 'mindful' | 'dietaryEnergy' | 'dietaryProtein'
@@ -356,6 +364,42 @@ export interface BuildOptions {
356364 runtimeDir?: string | null
357365}
358366
367/**
368 * Run the app's declared generator hook without invoking a shell.
369 *
370 * A hook gets the generated project as its cwd and explicit metadata so a
371 * patch can target the right platform and phase without guessing from paths.
372 */
373export function runPostGenerateHook(
374 hook: CraftPostGenerateHook | undefined,
375 output: string,
376 phase: 'build' = 'build',
377): void {
378 if (!hook) return
379 if (typeof hook.command !== 'string' || !hook.command.trim())
380 throw new Error('Android postGenerate.command must be a non-empty executable')
381 if (hook.args !== undefined && (!Array.isArray(hook.args) || hook.args.some(argument => typeof argument !== 'string')))
382 throw new Error('Android postGenerate.args must be an array of strings')
383
384 const command = [hook.command, ...(hook.args ?? [])]
385 let result: ReturnType<typeof Bun.spawnSync>
386 try {
387 result = Bun.spawnSync(command, {
388 cwd: output,
389 env: { ...process.env, CRAFT_PROJECT_ROOT: output, CRAFT_PLATFORM: 'android', CRAFT_GENERATE_PHASE: phase },
390 stdout: 'inherit',
391 stderr: 'pipe',
392 })
393 }
394 catch (error) {
395 throw new Error(`Android postGenerate hook could not start: ${hook.command}`, { cause: error })
396 }
397 if (result.exitCode !== 0) {
398 const stderr = result.stderr?.toString().trim()
399 throw new Error(`Android postGenerate hook failed with exit code ${result.exitCode}${stderr ? `: ${stderr}` : ''}`)
400 }
401}
402
359403export interface OpenOptions {
360404 output: string
361405}
@@ -1577,6 +1621,8 @@ export async function build(options: BuildOptions): Promise<void> {
15771621
15781622 syncNativeFonts(config.nativeFonts, output)
15791623
1624 runPostGenerateHook(config.postGenerate, output)
1625
15801626 if (!compile) return
15811627
15821628 // Check if gradlew exists
packages/ios/src/index.test.tsmodified+21-1
Changes to packages/ios/src/index.test.ts
@@ -1,4 +1,4 @@
1import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
1import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
22import { join, resolve } from 'node:path'
33import { afterAll, describe, expect, it } from 'bun:test'
44import { addSchemeBuildTargets, addSchemeTestTargets, insertProjectTargetsBeforeSchemes } from '../scripts/insert-project-targets'
@@ -29,6 +29,7 @@ import {
2929 renderWatchEntitlements,
3030 resolveCraftVersion,
3131 restoreShell,
32 runPostGenerateHook,
3233 syncWebAssets,
3334 syncNativeFonts,
3435} from './index'
@@ -38,6 +39,25 @@ import { createTempDir, removeAllTempDirs } from '../../typescript/src/temp-dir'
3839afterAll(removeAllTempDirs)
3940
4041describe('Craft iOS builder', () => {
42 it('runs a declared postGenerate command from the project root with metadata', async () => {
43 const output = createTempDir('craft-ios-post-generate-')
44 await init({ name: 'Hook App', output, runtimeDir: null, config: {
45 postGenerate: { command: 'sh', args: ['-c', 'printf "%s|%s|%s" "$PWD" "$CRAFT_PLATFORM" "$CRAFT_GENERATE_PHASE" > hook.txt'] },
46 } })
47 const config = JSON.parse(readFileSync(join(output, 'craft.config.json'), 'utf8'))
48 expect(config.postGenerate).toEqual({ command: 'sh', args: ['-c', 'printf "%s|%s|%s" "$PWD" "$CRAFT_PLATFORM" "$CRAFT_GENERATE_PHASE" > hook.txt'] })
49 runPostGenerateHook(config.postGenerate, output)
50 expect(readFileSync(join(output, 'hook.txt'), 'utf8')).toBe(`${realpathSync(output)}|ios|build`)
51 })
52
53 it('reports invalid iOS postGenerate commands and non-zero exits', () => {
54 const output = createTempDir('craft-ios-post-generate-errors-')
55 expect(() => runPostGenerateHook({ command: '' }, output)).toThrow('postGenerate.command must be a non-empty executable')
56 expect(() => runPostGenerateHook({ command: join(output, 'missing-hook') }, output)).toThrow('postGenerate hook could not start')
57 expect(() => runPostGenerateHook({ command: 'sh', args: ['-c', 'printf "patch failed" >&2; exit 7'] }, output))
58 .toThrow('postGenerate hook failed with exit code 7: patch failed')
59 })
60
4161 it('restores the shell with the actual Xcode Cloud build identity without changing other resources', () => {
4262 const output = createTempDir('craft-cloud-identity-')
4363 const file = join(output, 'craft.config.json')
packages/ios/src/index.tsmodified+46-0
Changes to packages/ios/src/index.ts
@@ -68,6 +68,12 @@ const CRAFT_VERSION = resolveCraftVersion()
6868
6969export type CraftAppearance = 'light' | 'dark' | 'system'
7070
71/** A declared command Craft runs after the native project has been generated. */
72export interface CraftPostGenerateHook {
73 command: string
74 args?: string[]
75}
76
7177export interface CraftConfig {
7278 appShortcuts?: CraftAppShortcut[]
7379 /** Bundled font files keyed by their PostScript font name, used as native fontFamily. */
@@ -86,6 +92,8 @@ export interface CraftConfig {
8692 * and `dark` pin one. Unset keeps the older `darkMode` switch.
8793 */
8894 appearance?: CraftAppearance
95 /** Runs from the generated project root after `craft ios build` writes it. */
96 postGenerate?: CraftPostGenerateHook
8997 backgroundColor?: string
9098 /** The launch and webview background while the phone is in Dark Mode. */
9199 backgroundColorDark?: string
@@ -639,6 +647,42 @@ export interface BuildOptions {
639647 runtimeDir?: string | null
640648}
641649
650/**
651 * Run the app's declared generator hook without invoking a shell.
652 *
653 * A hook gets the generated project as its cwd and explicit metadata so a
654 * patch can target the right platform and phase without guessing from paths.
655 */
656export function runPostGenerateHook(
657 hook: CraftPostGenerateHook | undefined,
658 output: string,
659 phase: 'build' = 'build',
660): void {
661 if (!hook) return
662 if (typeof hook.command !== 'string' || !hook.command.trim())
663 throw new Error('iOS postGenerate.command must be a non-empty executable')
664 if (hook.args !== undefined && (!Array.isArray(hook.args) || hook.args.some(argument => typeof argument !== 'string')))
665 throw new Error('iOS postGenerate.args must be an array of strings')
666
667 const command = [hook.command, ...(hook.args ?? [])]
668 let result: ReturnType<typeof Bun.spawnSync>
669 try {
670 result = Bun.spawnSync(command, {
671 cwd: output,
672 env: { ...process.env, CRAFT_PROJECT_ROOT: output, CRAFT_PLATFORM: 'ios', CRAFT_GENERATE_PHASE: phase },
673 stdout: 'inherit',
674 stderr: 'pipe',
675 })
676 }
677 catch (error) {
678 throw new Error(`iOS postGenerate hook could not start: ${hook.command}`, { cause: error })
679 }
680 if (result.exitCode !== 0) {
681 const stderr = result.stderr?.toString().trim()
682 throw new Error(`iOS postGenerate hook failed with exit code ${result.exitCode}${stderr ? `: ${stderr}` : ''}`)
683 }
684}
685
642686export interface OpenOptions {
643687 output: string
644688}
@@ -1755,6 +1799,8 @@ catch (error) {
17551799 })
17561800 }
17571801
1802 runPostGenerateHook(config.postGenerate, output)
1803
17581804 console.log('')
17591805}
17601806