also looking at this
feat: finish native customization and platform seams
#401
21 files
+707
-17
| @@ -138,6 +138,10 @@ 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 | /** Static native tab-bar styling applied before the page's own tabBar.set call. */ | |
| 142 | tabBar?: CraftTabBarConfig | |
| 143 | /** Runs from the generated project root before Gradle compiles the app. */ | |
| 144 | postGenerate?: CraftPostGenerateHook | |
| 141 | 145 | version?: string |
| 142 | 146 | versionCode?: number |
| 143 | 147 | darkMode?: boolean |
| @@ -261,6 +265,21 @@ export interface CraftAndroidConfig { | ||
| 261 | 265 | } |
| 262 | 266 | } |
| 263 | 267 | |
| 268 | export interface CraftTabBarConfig { | |
| 269 | selection?: { | |
| 270 | /** The selected tab indicator's corner radius in dp; omitted keeps the default. */ | |
| 271 | cornerRadius?: number | |
| 272 | /** The selected tab indicator fill as #RRGGBB. */ | |
| 273 | color?: string | |
| 274 | } | |
| 275 | } | |
| 276 | ||
| 277 | /** A declared command Craft runs after the native project has been generated. */ | |
| 278 | export interface CraftPostGenerateHook { | |
| 279 | command: string | |
| 280 | args?: string[] | |
| 281 | } | |
| 282 | ||
| 264 | 283 | /** The shared health data types (the iOS builder's HealthKit names). */ |
| 265 | 284 | export type HealthConnectType = 'steps' | 'heartRate' | 'activeEnergy' | 'distance' | 'workouts' | 'restingHeartRate' |
| 266 | 285 | | 'heartRateVariability' | 'bodyMass' | 'sleep' | 'mindful' | 'dietaryEnergy' | 'dietaryProtein' |
| @@ -356,6 +375,42 @@ export interface BuildOptions { | ||
| 356 | 375 | runtimeDir?: string | null |
| 357 | 376 | } |
| 358 | 377 | |
| 378 | /** | |
| 379 | * Run the app's declared generator hook without invoking a shell. | |
| 380 | * | |
| 381 | * A hook gets the generated project as its cwd and explicit metadata so a | |
| 382 | * patch can target the right platform and phase without guessing from paths. | |
| 383 | */ | |
| 384 | export function runPostGenerateHook( | |
| 385 | hook: CraftPostGenerateHook | undefined, | |
| 386 | output: string, | |
| 387 | phase: 'build' = 'build', | |
| 388 | ): void { | |
| 389 | if (!hook) return | |
| 390 | if (typeof hook.command !== 'string' || !hook.command.trim()) | |
| 391 | throw new Error('Android postGenerate.command must be a non-empty executable') | |
| 392 | if (hook.args !== undefined && (!Array.isArray(hook.args) || hook.args.some(argument => typeof argument !== 'string'))) | |
| 393 | throw new Error('Android postGenerate.args must be an array of strings') | |
| 394 | ||
| 395 | const command = [hook.command, ...(hook.args ?? [])] | |
| 396 | let result: ReturnType<typeof Bun.spawnSync> | |
| 397 | try { | |
| 398 | result = Bun.spawnSync(command, { | |
| 399 | cwd: output, | |
| 400 | env: { ...process.env, CRAFT_PROJECT_ROOT: output, CRAFT_PLATFORM: 'android', CRAFT_GENERATE_PHASE: phase }, | |
| 401 | stdout: 'inherit', | |
| 402 | stderr: 'pipe', | |
| 403 | }) | |
| 404 | } | |
| 405 | catch (error) { | |
| 406 | throw new Error(`Android postGenerate hook could not start in ${output}: ${hook.command}`, { cause: error }) | |
| 407 | } | |
| 408 | if (result.exitCode !== 0) { | |
| 409 | const stderr = result.stderr?.toString().trim() | |
| 410 | throw new Error(`Android postGenerate hook failed in ${output} with exit code ${result.exitCode}${stderr ? `: ${stderr}` : ''}`) | |
| 411 | } | |
| 412 | } | |
| 413 | ||
| 359 | 414 | export interface OpenOptions { |
| 360 | 415 | output: string |
| 361 | 416 | } |
| @@ -521,6 +576,11 @@ function validateAndroidConfig(config: CraftAndroidConfig): void { | ||
| 521 | 576 | if (config.appearance !== undefined && !['light', 'dark', 'system'].includes(config.appearance)) { |
| 522 | 577 | throw new Error(`Unknown Android appearance: ${config.appearance}. Expected light, dark, or system.`) |
| 523 | 578 | } |
| 579 | const selection = config.tabBar?.selection | |
| 580 | if (selection?.cornerRadius !== undefined && (!Number.isFinite(selection.cornerRadius) || selection.cornerRadius < 0)) | |
| 581 | throw new Error('Android tabBar.selection.cornerRadius must be a finite non-negative number') | |
| 582 | if (selection?.color !== undefined && !/^#[0-9a-f]{6}$/i.test(selection.color)) | |
| 583 | throw new Error('Android tabBar.selection.color must be a #RRGGBB color') | |
| 524 | 584 | |
| 525 | 585 | const packageSegments = config.packageName.split('.') |
| 526 | 586 | if (packageSegments.length < 2 |
| @@ -1577,6 +1637,8 @@ export async function build(options: BuildOptions): Promise<void> { | ||
| 1577 | 1637 | |
| 1578 | 1638 | syncNativeFonts(config.nativeFonts, output) |
| 1579 | 1639 | |
| 1640 | runPostGenerateHook(config.postGenerate, output) | |
| 1641 | ||
| 1580 | 1642 | if (!compile) return |
| 1581 | 1643 | |
| 1582 | 1644 | // Check if gradlew exists |