also looking at this
feat: finish native customization and platform seams
#401
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.
| @@ -1,7 +1,7 @@ | ||
| 1 | import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' | |
| 1 | import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs' | |
| 2 | 2 | import { join } from 'node:path' |
| 3 | 3 | import { afterAll, describe, expect, it } from 'bun:test' |
| 4 | import { ANDROID_MIN_SDK, build, init, installRuntime, renderAndroidDeepLinks, renderAndroidPermissions, resolveRuntimeDir, syncAndroidWebAssets, syncNativeFonts } from './index' | |
| 4 | import { ANDROID_MIN_SDK, build, init, installRuntime, renderAndroidDeepLinks, renderAndroidPermissions, resolveRuntimeDir, runPostGenerateHook, syncAndroidWebAssets, syncNativeFonts } from './index' | |
| 5 | 5 | import { createTempDir, removeAllTempDirs } from '../../typescript/src/temp-dir' |
| 6 | 6 | |
| 7 | 7 | // Every scratch directory this file made goes when the file is done (#357). |
| @@ -15,6 +15,25 @@ function generatedFiles(path: string): string[] { | ||
| 15 | 15 | } |
| 16 | 16 | |
| 17 | 17 | describe('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 | ||
| 18 | 37 | it('bundles custom fonts with their licenses and removes retired fonts', async () => { |
| 19 | 38 | const root = createTempDir('craft-native-fonts-') |
| 20 | 39 | const font = join(root, 'Signature.ttf') |
| @@ -138,6 +138,8 @@ export interface CraftAndroidConfig { | ||
| 138 | 138 | renderer?: 'web' | 'native' |
| 139 | 139 | /** "system" follows the device; light/dark pin the generated host. */ |
| 140 | 140 | appearance?: 'light' | 'dark' | 'system' |
| 141 | /** Runs from the generated project root before Gradle compiles the app. */ | |
| 142 | postGenerate?: CraftPostGenerateHook | |
| 141 | 143 | version?: string |
| 142 | 144 | versionCode?: number |
| 143 | 145 | darkMode?: boolean |
| @@ -261,6 +263,12 @@ export interface CraftAndroidConfig { | ||
| 261 | 263 | } |
| 262 | 264 | } |
| 263 | 265 | |
| 266 | /** A declared command Craft runs after the native project has been generated. */ | |
| 267 | export interface CraftPostGenerateHook { | |
| 268 | command: string | |
| 269 | args?: string[] | |
| 270 | } | |
| 271 | ||
| 264 | 272 | /** The shared health data types (the iOS builder's HealthKit names). */ |
| 265 | 273 | export type HealthConnectType = 'steps' | 'heartRate' | 'activeEnergy' | 'distance' | 'workouts' | 'restingHeartRate' |
| 266 | 274 | | 'heartRateVariability' | 'bodyMass' | 'sleep' | 'mindful' | 'dietaryEnergy' | 'dietaryProtein' |
| @@ -356,6 +364,42 @@ export interface BuildOptions { | ||
| 356 | 364 | runtimeDir?: string | null |
| 357 | 365 | } |
| 358 | 366 | |
| 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 | */ | |
| 373 | export 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 | ||
| 359 | 403 | export interface OpenOptions { |
| 360 | 404 | output: string |
| 361 | 405 | } |
| @@ -1577,6 +1621,8 @@ export async function build(options: BuildOptions): Promise<void> { | ||
| 1577 | 1621 | |
| 1578 | 1622 | syncNativeFonts(config.nativeFonts, output) |
| 1579 | 1623 | |
| 1624 | runPostGenerateHook(config.postGenerate, output) | |
| 1625 | ||
| 1580 | 1626 | if (!compile) return |
| 1581 | 1627 | |
| 1582 | 1628 | // Check if gradlew exists |
| @@ -1,4 +1,4 @@ | ||
| 1 | import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' | |
| 1 | import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' | |
| 2 | 2 | import { join, resolve } from 'node:path' |
| 3 | 3 | import { afterAll, describe, expect, it } from 'bun:test' |
| 4 | 4 | import { addSchemeBuildTargets, addSchemeTestTargets, insertProjectTargetsBeforeSchemes } from '../scripts/insert-project-targets' |
| @@ -29,6 +29,7 @@ import { | ||
| 29 | 29 | renderWatchEntitlements, |
| 30 | 30 | resolveCraftVersion, |
| 31 | 31 | restoreShell, |
| 32 | runPostGenerateHook, | |
| 32 | 33 | syncWebAssets, |
| 33 | 34 | syncNativeFonts, |
| 34 | 35 | } from './index' |
| @@ -38,6 +39,25 @@ import { createTempDir, removeAllTempDirs } from '../../typescript/src/temp-dir' | ||
| 38 | 39 | afterAll(removeAllTempDirs) |
| 39 | 40 | |
| 40 | 41 | describe('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 | ||
| 41 | 61 | it('restores the shell with the actual Xcode Cloud build identity without changing other resources', () => { |
| 42 | 62 | const output = createTempDir('craft-cloud-identity-') |
| 43 | 63 | const file = join(output, 'craft.config.json') |
| @@ -68,6 +68,12 @@ const CRAFT_VERSION = resolveCraftVersion() | ||
| 68 | 68 | |
| 69 | 69 | export type CraftAppearance = 'light' | 'dark' | 'system' |
| 70 | 70 | |
| 71 | /** A declared command Craft runs after the native project has been generated. */ | |
| 72 | export interface CraftPostGenerateHook { | |
| 73 | command: string | |
| 74 | args?: string[] | |
| 75 | } | |
| 76 | ||
| 71 | 77 | export interface CraftConfig { |
| 72 | 78 | appShortcuts?: CraftAppShortcut[] |
| 73 | 79 | /** Bundled font files keyed by their PostScript font name, used as native fontFamily. */ |
| @@ -86,6 +92,8 @@ export interface CraftConfig { | ||
| 86 | 92 | * and `dark` pin one. Unset keeps the older `darkMode` switch. |
| 87 | 93 | */ |
| 88 | 94 | appearance?: CraftAppearance |
| 95 | /** Runs from the generated project root after `craft ios build` writes it. */ | |
| 96 | postGenerate?: CraftPostGenerateHook | |
| 89 | 97 | backgroundColor?: string |
| 90 | 98 | /** The launch and webview background while the phone is in Dark Mode. */ |
| 91 | 99 | backgroundColorDark?: string |
| @@ -639,6 +647,42 @@ export interface BuildOptions { | ||
| 639 | 647 | runtimeDir?: string | null |
| 640 | 648 | } |
| 641 | 649 | |
| 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 | */ | |
| 656 | export 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 | ||
| 642 | 686 | export interface OpenOptions { |
| 643 | 687 | output: string |
| 644 | 688 | } |
| @@ -1755,6 +1799,8 @@ catch (error) { | ||
| 1755 | 1799 | }) |
| 1756 | 1800 | } |
| 1757 | 1801 | |
| 1802 | runPostGenerateHook(config.postGenerate, output) | |
| 1803 | ||
| 1758 | 1804 | console.log('') |
| 1759 | 1805 | } |
| 1760 | 1806 | |