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
docs/guides/native-stx.mdmodified+30-0
Changes to docs/guides/native-stx.md
@@ -1052,6 +1052,36 @@ all: no `WKWebView` is ever created.
10521052 capabilities whenever `enableGeolocation` / `enableAppleMusic` are set. The
10531053 single-screen native app (no `nativeScreens`) gets them as well.
10541054
1055### Generated project customization
1056
1057Generated native sources are owned by Craft and may be replaced on the next
1058build. Use typed configuration for common chrome changes, and declare a hook
1059only for app-owned source changes that Craft does not model yet:
1060
1061```json
1062{
1063 "tabBar": {
1064 "selection": { "cornerRadius": 8, "color": "#123456" }
1065 },
1066 "postGenerate": {
1067 "command": "bun",
1068 "args": ["run", "scripts/customize-native.ts"]
1069 }
1070}
1071```
1072
1073The `tabBar.selection` option is shared by the iOS and Android builders. Its
1074`cornerRadius` is in points on iOS and dp on Android; `color` is `#RRGGBB`.
1075Omitted values preserve each host's default selection appearance.
1076
1077`postGenerate` runs after `craft ios build` or `craft android build` generates
1078the native project. The command runs with the generated project as its working
1079directory and is launched directly from `command` plus `args` (no shell
1080interpolation). Craft provides `CRAFT_PROJECT_ROOT`, `CRAFT_PLATFORM`, and
1081`CRAFT_GENERATE_PHASE=build`. Missing executables, malformed options, and
1082non-zero exits fail the build and include the hook's stderr, so a source patch
1083cannot silently stop applying after a template change.
1084
10551085### The app script (`nativeApp`)
10561086
10571087`nativeApp` names a bundle route that has no screen (Stacks names it `__app`).
packages/android/README.mdmodified+23-0
Changes to packages/android/README.md
@@ -484,6 +484,29 @@ changes; the other two values pin the generated host. If it is omitted,
484484`darkMode` remains the compatibility switch (`false` pins light, `true` pins
485485dark).
486486
487Native tab-bar selection styling is opt-in and lives in the generated config,
488so the generated Kotlin never needs to be patched:
489
490```json
491{
492 "tabBar": {
493 "selection": { "cornerRadius": 8, "color": "#123456" }
494 },
495 "postGenerate": {
496 "command": "bun",
497 "args": ["run", "scripts/customize-native.ts"]
498 }
499}
500```
501
502`cornerRadius` is measured in dp and `color` is `#RRGGBB`; omit either to keep
503its host default. `postGenerate` runs from the generated project root during
504`craft android build`, before Gradle compiles the app. Craft passes
505`command` and `args` directly without a shell, and supplies
506`CRAFT_PROJECT_ROOT`, `CRAFT_PLATFORM=android`, and
507`CRAFT_GENERATE_PHASE=build`. An invalid command or non-zero exit fails the
508build and includes the hook's stderr in the diagnostic.
509
487510Remote app and trusted-origin URLs must use HTTPS. Local development may use
488511HTTP with `localhost`, `127.0.0.1`, or the Android emulator host `10.0.2.2`.
489512Origins are normalized before they are stored, and a configured development
packages/android/src/index.test.tsmodified+52-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,56 @@ 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('runs the postGenerate hook during the Android build before compilation', async () => {
30 const output = createTempDir('craft-android-post-generate-build-')
31 await init({ name: 'Hook Build', output, runtimeDir: null, config: {
32 postGenerate: { command: 'sh', args: ['-c', 'printf "%s" "$CRAFT_GENERATE_PHASE" > hook-phase.txt'] },
33 } })
34 await build({ output, compile: false })
35 expect(readFileSync(join(output, 'hook-phase.txt'), 'utf8')).toBe('build')
36 })
37
38 it('reports invalid Android postGenerate commands and non-zero exits', () => {
39 const output = createTempDir('craft-android-post-generate-errors-')
40 expect(() => runPostGenerateHook({ command: '' }, output)).toThrow('postGenerate.command must be a non-empty executable')
41 expect(() => runPostGenerateHook({ command: join(output, 'missing-hook') }, output)).toThrow('postGenerate hook could not start')
42 expect(() => runPostGenerateHook({ command: 'sh', args: ['-c', 'printf "patch failed" >&2; exit 7'] }, output))
43 .toThrow('postGenerate hook failed')
44 })
45
46 it('generates static tab-bar selection appearance and validates its shape', async () => {
47 const output = createTempDir('craft-android-tab-bar-appearance-')
48 await init({ name: 'Tab Bar', output, runtimeDir: null, config: {
49 renderer: 'native',
50 tabBar: { selection: { cornerRadius: 8, color: '#123456' } },
51 } })
52 const config = JSON.parse(readFileSync(join(output, 'craft.config.json'), 'utf8'))
53 const shell = readFileSync(join(output, 'app/src/main/java/com/craft/tabbar/CraftNativeShell.kt'), 'utf8')
54 const activity = readFileSync(join(output, 'app/src/main/java/com/craft/tabbar/MainActivity.kt'), 'utf8')
55 expect(config.tabBar).toEqual({ selection: { cornerRadius: 8, color: '#123456' } })
56 expect(shell).toContain('data class CraftNativeTabBarAppearance')
57 expect(shell).toContain('private fun tabCellBackground(index: Int, dark: Boolean)')
58 expect(activity).toContain('routes.tabBarAppearance')
59 await expect(init({ name: 'Invalid Tab Bar', output: join(output, 'invalid'), runtimeDir: null, config: {
60 renderer: 'native', tabBar: { selection: { cornerRadius: -1 } },
61 } })).rejects.toThrow('cornerRadius must be a finite non-negative number')
62 await expect(init({ name: 'Invalid Tab Color', output: join(output, 'invalid-color'), runtimeDir: null, config: {
63 renderer: 'native', tabBar: { selection: { color: 'red' } },
64 } })).rejects.toThrow('color must be a #RRGGBB color')
65 })
66
67
1868 it('bundles custom fonts with their licenses and removes retired fonts', async () => {
1969 const root = createTempDir('craft-native-fonts-')
2070 const font = join(root, 'Signature.ttf')
packages/android/src/index.tsmodified+62-0
Changes to packages/android/src/index.ts
@@ -138,6 +138,10 @@ export interface CraftAndroidConfig {
138138 renderer?: 'web' | 'native'
139139 /** "system" follows the device; light/dark pin the generated host. */
140140 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
141145 version?: string
142146 versionCode?: number
143147 darkMode?: boolean
@@ -261,6 +265,21 @@ export interface CraftAndroidConfig {
261265 }
262266}
263267
268export 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. */
278export interface CraftPostGenerateHook {
279 command: string
280 args?: string[]
281}
282
264283/** The shared health data types (the iOS builder's HealthKit names). */
265284export type HealthConnectType = 'steps' | 'heartRate' | 'activeEnergy' | 'distance' | 'workouts' | 'restingHeartRate'
266285 | 'heartRateVariability' | 'bodyMass' | 'sleep' | 'mindful' | 'dietaryEnergy' | 'dietaryProtein'
@@ -356,6 +375,42 @@ export interface BuildOptions {
356375 runtimeDir?: string | null
357376}
358377
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 */
384export 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
359414export interface OpenOptions {
360415 output: string
361416}
@@ -521,6 +576,11 @@ function validateAndroidConfig(config: CraftAndroidConfig): void {
521576 if (config.appearance !== undefined && !['light', 'dark', 'system'].includes(config.appearance)) {
522577 throw new Error(`Unknown Android appearance: ${config.appearance}. Expected light, dark, or system.`)
523578 }
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')
524584
525585 const packageSegments = config.packageName.split('.')
526586 if (packageSegments.length < 2
@@ -1577,6 +1637,8 @@ export async function build(options: BuildOptions): Promise<void> {
15771637
15781638 syncNativeFonts(config.nativeFonts, output)
15791639
1640 runPostGenerateHook(config.postGenerate, output)
1641
15801642 if (!compile) return
15811643
15821644 // Check if gradlew exists
packages/android/templates/CraftNativeShell.kt.templatemodified+32-1
Changes to packages/android/templates/CraftNativeShell.kt.template
@@ -86,6 +86,11 @@ internal data class CraftNativeTab(
8686 val selectedSymbol: String? = null, val selectedIconTint: String? = null, val selectedIconPulse: Boolean? = null,
8787)
8888
89internal data class CraftNativeTabBarAppearance(
90 val selectionColor: String? = null,
91 val selectionCornerRadius: Float? = null,
92)
93
8994internal data class CraftTabIconAppearance(val symbol: String, val tint: Int?, val pulse: Boolean)
9095
9196/** The app's paths (`nativeScreens`), its tabs and its site. */
@@ -122,6 +127,16 @@ internal class CraftNativeRoutes(config: JSONObject) {
122127 }
123128 }
124129
130 val tabBarAppearance: CraftNativeTabBarAppearance = config.optJSONObject("tabBar")
131 ?.optJSONObject("selection")
132 ?.let { selection ->
133 CraftNativeTabBarAppearance(
134 selection.optString("color").takeIf { it.isNotBlank() },
135 (selection.opt("cornerRadius") as? Number)?.toFloat()?.takeIf { it.isFinite() && it >= 0 },
136 )
137 }
138 ?: CraftNativeTabBarAppearance()
139
125140 /** The app's site (`devServerURL`): its origin answers paths no screen draws. */
126141 val site: Uri? = config.optString("devServerURL").takeIf { it.isNotBlank() }?.let(Uri::parse)
127142 val siteOrigin: String? = site?.let { uri ->
@@ -755,7 +770,11 @@ internal object CraftNativePrelude {
755770 * `craft.navigation.setOptions` (title, largeTitle, hidden, rightButtons,
756771 * tabAccessory, tabBarHidden); the tabs come from the app's `tabs`.
757772 */
758internal class CraftNativeChrome(context: Context, private val tabs: List<CraftNativeTab>) : LinearLayout(context) {
773internal class CraftNativeChrome(
774 context: Context,
775 private val tabs: List<CraftNativeTab>,
776 private val tabBarAppearance: CraftNativeTabBarAppearance = CraftNativeTabBarAppearance(),
777) : LinearLayout(context) {
759778 val content = FrameLayout(context)
760779 private val bar = LinearLayout(context)
761780 private val backButton = ImageView(context)
@@ -1099,6 +1118,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
10991118 accessorySpec?.let { accessoryIcon.setImageDrawable(CraftNativeIcons.drawable(context, null, it.optString("symbol"), text)) }
11001119 tabViews.forEachIndexed { index, (icon, label) ->
11011120 tabBar.getChildAt(index).isSelected = index == selectedTab
1121 tabBar.getChildAt(index).background = tabCellBackground(index, dark)
11021122 val color = if (index == selectedTab) accent else muted
11031123 val id = tabs[index].id
11041124 val number = tabIconText[id]
@@ -1188,6 +1208,17 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
11881208
11891209 private fun accentColor(dark: Boolean): Int = if (dark) Color.parseColor("#0A84FF") else Color.parseColor("#007AFF")
11901210
1211 private fun tabCellBackground(index: Int, dark: Boolean): android.graphics.drawable.Drawable? {
1212 if (index != selectedTab || (tabBarAppearance.selectionColor == null && tabBarAppearance.selectionCornerRadius == null)) return rippleBackground()
1213 val color = tabBarAppearance.selectionColor?.let { runCatching { Color.parseColor(it) }.getOrNull() }
1214 ?: if (dark) Color.argb(42, 255, 255, 255) else Color.argb(23, 0, 0, 0)
1215 val radius = tabBarAppearance.selectionCornerRadius?.let { (it * density).toInt().toFloat() } ?: dp(32).toFloat()
1216 return GradientDrawable().apply {
1217 setColor(color)
1218 cornerRadius = radius
1219 }
1220 }
1221
11911222 private fun rippleBackground(): android.graphics.drawable.Drawable? {
11921223 val value = TypedValue()
11931224 return if (context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless, value, true)) {
packages/android/templates/MainActivityNative.kt.templatemodified+1-1
Changes to packages/android/templates/MainActivityNative.kt.template
@@ -4198,7 +4198,7 @@ class MainActivity : AppCompatActivity() {
41984198 }
41994199
42004200 private fun installChrome() {
4201 val bars = CraftNativeChrome(this, routes.tabs)
4201 val bars = CraftNativeChrome(this, routes.tabs, routes.tabBarAppearance)
42024202 bars.layoutParams = FrameLayout.LayoutParams(-1, -1)
42034203 bars.onBack = { onBackPressedDispatcherCompat() }
42044204 bars.onAccessory = { path -> navigateOpen(path) }
packages/ios/README.mdmodified+23-0
Changes to packages/ios/README.md
@@ -391,6 +391,29 @@ Appearance and navigation:
391391- `swipeNavigation`: an edge swipe goes back and forward through the page's
392392 history, pushState entries included.
393393
394Native tab-bar selection styling is opt-in and lives in the generated config,
395so the generated Swift never needs to be patched:
396
397```json
398{
399 "tabBar": {
400 "selection": { "cornerRadius": 8, "color": "#123456" }
401 },
402 "postGenerate": {
403 "command": "bun",
404 "args": ["run", "scripts/customize-native.ts"]
405 }
406}
407```
408
409`cornerRadius` is measured in points and `color` is `#RRGGBB`; omit either to
410keep its host default. `postGenerate` runs after `craft ios build` has written
411the Xcode project, with the generated project as its working directory. Craft
412does not invoke a shell: `command` and `args` are passed directly. The hook
413receives `CRAFT_PROJECT_ROOT`, `CRAFT_PLATFORM=ios`, and
414`CRAFT_GENERATE_PHASE=build`. A missing executable, non-zero exit, or invalid
415configuration fails the build with the hook's stderr in the diagnostic.
416
394417Debug builds let Safari's Develop menu inspect the webview, and relay the
395418page's console, uncaught errors, rejected promises and failed script loads to
396419the device log as `[craft page] …`, so a page that only breaks inside the app
packages/ios/fixtures/native-screen/NativeLayoutUnitTests.swiftmodified+18-0
Changes to packages/ios/fixtures/native-screen/NativeLayoutUnitTests.swift
@@ -17,6 +17,24 @@ final class NativeLayoutUnitTests: XCTestCase {
1717 return controller
1818 }
1919
20 func testNativeScreenUsesConfiguredUIKitAppearance() {
21 var config = CraftConfig()
22 config.appearance = "light"
23 let light = CraftNativeScreenController(config: config)
24 light.loadViewIfNeeded()
25 XCTAssertEqual(light.overrideUserInterfaceStyle, .light)
26
27 config.appearance = "dark"
28 let dark = CraftNativeScreenController(config: config)
29 dark.loadViewIfNeeded()
30 XCTAssertEqual(dark.overrideUserInterfaceStyle, .dark)
31
32 config.appearance = "system"
33 let system = CraftNativeScreenController(config: config)
34 system.loadViewIfNeeded()
35 XCTAssertEqual(system.overrideUserInterfaceStyle, .unspecified)
36 }
37
2038 func testReturningToAnUnchangedThemeRetainsTextAndScrollGeometry() throws {
2139 let rows: [[String: Any]] = (0..<100).map { index in
2240 ["id": "row-\(index)", "type": "Text", "props": ["testID": "row-\(index)"],
packages/ios/src/index.test.tsmodified+88-2
Changes to packages/ios/src/index.test.ts
@@ -1,4 +1,4 @@
1import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
1import { chmodSync, 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,68 @@ 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('runs the postGenerate hook after the iOS builder generates its Xcode project', async () => {
54 const root = createTempDir('craft-ios-post-generate-build-')
55 const output = join(root, 'app')
56 const xcodegen = join(root, 'xcodegen')
57 writeFileSync(xcodegen, '#!/bin/sh\nmkdir -p HookBuild.xcodeproj\n')
58 chmodSync(xcodegen, 0o755)
59 const previousPath = process.env.PATH
60 process.env.PATH = `${root}:${previousPath ?? ''}`
61 try {
62 await init({ name: 'Hook Build', output, runtimeDir: null, config: {
63 postGenerate: { command: 'sh', args: ['-c', 'test -d HookBuild.xcodeproj && printf "%s" "$CRAFT_GENERATE_PHASE" > hook-phase.txt'] },
64 } })
65 await build({ output, runtimeDir: null })
66 expect(readFileSync(join(output, 'hook-phase.txt'), 'utf8')).toBe('build')
67 }
68 finally {
69 if (previousPath === undefined) delete process.env.PATH
70 else process.env.PATH = previousPath
71 }
72 })
73
74 it('reports invalid iOS postGenerate commands and non-zero exits', () => {
75 const output = createTempDir('craft-ios-post-generate-errors-')
76 expect(() => runPostGenerateHook({ command: '' }, output)).toThrow('postGenerate.command must be a non-empty executable')
77 expect(() => runPostGenerateHook({ command: join(output, 'missing-hook') }, output)).toThrow('postGenerate hook could not start')
78 expect(() => runPostGenerateHook({ command: 'sh', args: ['-c', 'printf "patch failed" >&2; exit 7'] }, output))
79 .toThrow('postGenerate hook failed')
80 })
81
82 it('generates static tab-bar selection appearance and validates its shape', async () => {
83 const output = createTempDir('craft-ios-tab-bar-appearance-')
84 await init({ name: 'Tab Bar', output, runtimeDir: null, config: {
85 tabBar: { selection: { cornerRadius: 8, color: '#123456' } },
86 } })
87 const config = JSON.parse(readFileSync(join(output, 'craft.config.json'), 'utf8'))
88 const swift = readFileSync(join(output, 'Sources', 'TabBarApp.swift'), 'utf8')
89 const hybrid = readFileSync(join(output, 'Sources', 'CraftHybrid.swift'), 'utf8')
90 expect(config.tabBar).toEqual({ selection: { cornerRadius: 8, color: '#123456' } })
91 expect(swift).toContain('private(set) var tabSelectionColor: UIColor?')
92 expect(swift).toContain('appearance: CraftConfig.TabBar? = nil')
93 expect(swift).toContain('chrome.tabSelectionColor.map { Color($0) }')
94 expect(swift).toContain('@ObservedObject private var chrome = CraftChrome.shared')
95 expect(hybrid).toContain('appearance: config.tabBar')
96 await expect(init({ name: 'Invalid Tab Bar', output: join(output, 'invalid'), runtimeDir: null, config: {
97 tabBar: { selection: { cornerRadius: -1 } },
98 } })).rejects.toThrow('cornerRadius must be a finite non-negative number')
99 await expect(init({ name: 'Invalid Tab Color', output: join(output, 'invalid-color'), runtimeDir: null, config: {
100 tabBar: { selection: { color: 'red' } },
101 } })).rejects.toThrow('color must be a #RRGGBB color')
102 })
103
41104 it('restores the shell with the actual Xcode Cloud build identity without changing other resources', () => {
42105 const output = createTempDir('craft-cloud-identity-')
43106 const file = join(output, 'craft.config.json')
@@ -799,7 +862,7 @@ describe('Craft iOS builder', () => {
799862 const launch = JSON.parse(readFileSync(join(output, 'Assets.xcassets', 'LaunchBackground.colorset', 'Contents.json'), 'utf8'))
800863 expect(plist).toContain('<key>UIUserInterfaceStyle</key>\n <string>Automatic</string>')
801864 expect(plist).toContain('<string>UIStatusBarStyleDefault</string>')
802 expect(swift).toContain('.preferredColorScheme(appState.config.colorScheme)')
865 expect(swift).toContain('.preferredColorScheme(appState.appearanceScheme)')
803866 expect(launch.colors).toHaveLength(2)
804867 expect(launch.colors[1].appearances).toEqual([{ appearance: 'luminosity', value: 'dark' }])
805868 expect(launch.colors[1].color.components.red).toBe('0.008')
@@ -830,6 +893,27 @@ describe('Craft iOS builder', () => {
830893 expect(source).toContain('renderCommitted(document)')
831894 })
832895
896 it('lets native screens pin UIKit appearance at launch and at runtime', async () => {
897 const output = createTempDir('craft-ios-native-appearance-')
898 await init({
899 runtimeDir: null,
900 name: 'NativeAppearance',
901 bundleId: 'org.example.native-appearance',
902 output,
903 config: { renderer: 'native', appearance: 'system' },
904 })
905 const swift = readFileSync(join(output, 'Sources', 'NativeAppearanceApp.swift'), 'utf8')
906 const screen = readFileSync(join(output, 'Sources', 'CraftNativeScreen.swift'), 'utf8')
907 expect(swift).toContain('CraftInterfaceStyleController.configure(config)')
908 expect(swift).toContain('CraftInterfaceStyleController.set(appearance)')
909 expect(swift).toContain('.preferredColorScheme(appState.appearanceScheme)')
910 expect(swift).toContain('CraftInterfaceStyleController.colorScheme(for: self.config)')
911 expect(swift).toContain('case "getAppearance":')
912 expect(screen).toContain('CraftInterfaceStyleController.apply(to: self, config: config)')
913 expect(screen).toContain('craftNativeSetAppearance')
914 expect(screen).toContain("keep('app', {")
915 })
916
833917 it('emits native layout measurements only when a view changes frame', async () => {
834918 const output = createTempDir('craft-ios-native-layout-')
835919 await init({
@@ -2567,6 +2651,8 @@ describe('Craft iOS builder', () => {
25672651 expect(watch).toContain('protocolVersion')
25682652 expect(watch).toContain('requestId')
25692653 expect(watch).toContain('stateVersion')
2654 expect(watch).toContain('guard incomingVersion >= stateVersion else { return }')
2655 expect(watch).toContain('stateVersion = incomingVersion')
25702656 expect(watch).toContain('planned-session')
25712657 expect(watch).toContain('craft.watch.state.v1')
25722658 expect(watch).toContain('updateApplicationContext(state)')
packages/ios/src/index.tsmodified+62-0
Changes to packages/ios/src/index.ts
@@ -68,6 +68,21 @@ 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
77export interface CraftTabBarConfig {
78 selection?: {
79 /** The selected tab indicator's corner radius in points; omitted keeps a capsule. */
80 cornerRadius?: number
81 /** The selected tab indicator fill as #RRGGBB. */
82 color?: string
83 }
84}
85
7186export interface CraftConfig {
7287 appShortcuts?: CraftAppShortcut[]
7388 /** Bundled font files keyed by their PostScript font name, used as native fontFamily. */
@@ -86,6 +101,10 @@ export interface CraftConfig {
86101 * and `dark` pin one. Unset keeps the older `darkMode` switch.
87102 */
88103 appearance?: CraftAppearance
104 /** Static native tab-bar styling applied before the page's own tabBar.set call. */
105 tabBar?: CraftTabBarConfig
106 /** Runs from the generated project root after `craft ios build` writes it. */
107 postGenerate?: CraftPostGenerateHook
89108 backgroundColor?: string
90109 /** The launch and webview background while the phone is in Dark Mode. */
91110 backgroundColorDark?: string
@@ -639,6 +658,42 @@ export interface BuildOptions {
639658 runtimeDir?: string | null
640659}
641660
661/**
662 * Run the app's declared generator hook without invoking a shell.
663 *
664 * A hook gets the generated project as its cwd and explicit metadata so a
665 * patch can target the right platform and phase without guessing from paths.
666 */
667export function runPostGenerateHook(
668 hook: CraftPostGenerateHook | undefined,
669 output: string,
670 phase: 'build' = 'build',
671): void {
672 if (!hook) return
673 if (typeof hook.command !== 'string' || !hook.command.trim())
674 throw new Error('iOS postGenerate.command must be a non-empty executable')
675 if (hook.args !== undefined && (!Array.isArray(hook.args) || hook.args.some(argument => typeof argument !== 'string')))
676 throw new Error('iOS postGenerate.args must be an array of strings')
677
678 const command = [hook.command, ...(hook.args ?? [])]
679 let result: ReturnType<typeof Bun.spawnSync>
680 try {
681 result = Bun.spawnSync(command, {
682 cwd: output,
683 env: { ...process.env, CRAFT_PROJECT_ROOT: output, CRAFT_PLATFORM: 'ios', CRAFT_GENERATE_PHASE: phase },
684 stdout: 'inherit',
685 stderr: 'pipe',
686 })
687 }
688 catch (error) {
689 throw new Error(`iOS postGenerate hook could not start in ${output}: ${hook.command}`, { cause: error })
690 }
691 if (result.exitCode !== 0) {
692 const stderr = result.stderr?.toString().trim()
693 throw new Error(`iOS postGenerate hook failed in ${output} with exit code ${result.exitCode}${stderr ? `: ${stderr}` : ''}`)
694 }
695}
696
642697export interface OpenOptions {
643698 output: string
644699}
@@ -1502,6 +1557,11 @@ export async function init(options: InitOptions): Promise<void> {
15021557 if (config.renderer !== 'web' && config.renderer !== 'native') {
15031558 throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`)
15041559 }
1560 const selection = config.tabBar?.selection
1561 if (selection?.cornerRadius !== undefined && (!Number.isFinite(selection.cornerRadius) || selection.cornerRadius < 0))
1562 throw new Error('iOS tabBar.selection.cornerRadius must be a finite non-negative number')
1563 if (selection?.color !== undefined && !/^#[0-9a-f]{6}$/i.test(selection.color))
1564 throw new Error('iOS tabBar.selection.color must be a #RRGGBB color')
15051565 if (config.enableBackgroundLocation) config.enableGeolocation = true
15061566 const problems = hybridConfigProblems(config)
15071567 if (problems.length) throw new Error(problems.join('\n'))
@@ -1755,6 +1815,8 @@ catch (error) {
17551815 })
17561816 }
17571817
1818 runPostGenerateHook(config.postGenerate, output)
1819
17581820 console.log('')
17591821}
17601822
packages/ios/templates/CraftApp.swiftmodified+153-3
Changes to packages/ios/templates/CraftApp.swift
@@ -102,6 +102,96 @@ enum CraftPageAppearance {
102102 }
103103}
104104
105/// The app-wide UIKit appearance override. SwiftUI's `preferredColorScheme`
106/// only controls the SwiftUI environment; native screen controllers need the
107/// UIKit override as well so their trait collection, controls and navigation
108/// chrome resolve the same way. A runtime choice is kept here so screens
109/// created after the choice inherit it instead of reverting to the bundle
110/// config.
111enum CraftInterfaceStyleController {
112 /// The app's bundled choice is the default for newly-created screens.
113 /// A runtime choice is separate: it is the only value that should
114 /// override a screen's own generated config.
115 private static var configured: UIUserInterfaceStyle = .unspecified
116 private static var runtimeSelection: UIUserInterfaceStyle?
117
118 static func configuredStyle(for config: CraftConfig) -> UIUserInterfaceStyle {
119 switch config.appearance {
120 case "system": return .unspecified
121 case "light": return .light
122 case "dark": return .dark
123 default: return config.darkMode ? .dark : .light
124 }
125 }
126
127 static func configure(_ config: CraftConfig) {
128 configured = configuredStyle(for: config)
129 runtimeSelection = nil
130 applyToWindows()
131 }
132
133 static func style(for config: CraftConfig) -> UIUserInterfaceStyle {
134 runtimeSelection ?? configuredStyle(for: config)
135 }
136
137 static func colorScheme(for config: CraftConfig) -> ColorScheme? {
138 switch style(for: config) {
139 case .light: return .light
140 case .dark: return .dark
141 default: return nil
142 }
143 }
144
145 /// Returns false rather than silently accepting a typo from JavaScript.
146 @discardableResult
147 static func set(_ value: String) -> Bool {
148 let style: UIUserInterfaceStyle
149 switch value {
150 case "system": style = .unspecified
151 case "light": style = .light
152 case "dark": style = .dark
153 default: return false
154 }
155 runtimeSelection = style
156 applyToWindows()
157 NotificationCenter.default.post(name: .craftColorSchemeChanged, object: nil)
158 return true
159 }
160
161 static func currentName() -> String {
162 let requested = runtimeSelection ?? UIApplication.shared.connectedScenes
163 .compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow })?.traitCollection.userInterfaceStyle }
164 .first ?? .unspecified
165 let style = requested == .unspecified
166 ? (UIApplication.shared.connectedScenes
167 .compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow })?.traitCollection.userInterfaceStyle }
168 .first { $0 != .unspecified } ?? UITraitCollection.current.userInterfaceStyle)
169 : requested
170 return style == .dark ? "dark" : "light"
171 }
172
173 static func apply(to controller: UIViewController, config: CraftConfig) {
174 controller.overrideUserInterfaceStyle = style(for: config)
175 }
176
177 private static func applyToWindows() {
178 let style = runtimeSelection ?? configured
179 for scene in UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }) {
180 for window in scene.windows {
181 window.overrideUserInterfaceStyle = style
182 apply(style, to: window.rootViewController)
183 }
184 }
185 }
186
187 private static func apply(_ style: UIUserInterfaceStyle, to controller: UIViewController?) {
188 guard let controller else { return }
189 controller.overrideUserInterfaceStyle = style
190 controller.children.forEach { apply(style, to: $0) }
191 if let presented = controller.presentedViewController { apply(style, to: presented) }
192 }
193}
194
105195/// Tells the shell when SwiftUI's colour scheme changes: the one appearance
106196/// setting with no notification of its own.
107197struct CraftColorSchemeObserver: View {
@@ -195,6 +285,7 @@ struct CraftRecoveryBudget {
195285final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
196286 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
197287 let config = CraftConfig.bundled()
288 CraftInterfaceStyleController.configure(config)
198289 // First, so a crash anywhere in the rest of launch is reported too.
199290 CraftCrashReporter.shared.start(config)
200291 UNUserNotificationCenter.current().delegate = self
@@ -606,7 +697,7 @@ struct CraftApp: App {
606697 .ignoresSafeArea(.keyboard, edges: .bottom)
607698 }
608699 }
609 .preferredColorScheme(appState.config.colorScheme)
700 .preferredColorScheme(appState.appearanceScheme)
610701 .environmentObject(appState)
611702 .onOpenURL { url in
612703 // Handle deep links and universal links
@@ -689,6 +780,10 @@ final class CraftChrome: ObservableObject {
689780 /// The selected tab highlight's corner radius. Nil preserves the default
690781 /// capsule, while a finite non-negative value opts into a rounded rect.
691782 private(set) var tabSelectionCornerRadius: CGFloat?
783 /// The configured static shape, retained when the page updates only its
784 /// tabs or selected item at runtime.
785 private var configuredTabSelectionCornerRadius: CGFloat?
786 private(set) var tabSelectionColor: UIColor?
692787
693788 weak var webView: WKWebView?
694789 /// What the tab bar covers at the bottom of the screen, in points.
@@ -723,9 +818,13 @@ final class CraftChrome: ObservableObject {
723818 /// The tab bar from `tabs` in craft.config.json, there before the page
724819 /// loads; the page's own `tabBar` message replaces it later. `splash`
725820 /// false when the first screen is native and draws itself.
726 func configure(tabs configured: [CraftConfig.Tab], selected: String?, splash: Bool) {
821 func configure(tabs configured: [CraftConfig.Tab], selected: String?, splash: Bool, appearance: CraftConfig.TabBar? = nil) {
727822 splashVisible = splash
728823 tabSwipeEnabled = true
824 configuredTabSelectionCornerRadius = appearance?.selection?.cornerRadius
825 .flatMap { $0.isFinite && $0 >= 0 ? CGFloat($0) : nil }
826 tabSelectionCornerRadius = configuredTabSelectionCornerRadius
827 tabSelectionColor = appearance?.selection?.color.flatMap { UIColor(hex: $0) }
729828 guard !configured.isEmpty else { return }
730829 tabs = configured.map { Tab(id: $0.id, title: $0.title, symbol: $0.symbol ?? "circle", badge: nil, selectedSymbol: Tab.normalizedSelectedSymbol($0.selectedSymbol), selectedIconTint: $0.selectedIconTint, selectedIconPulse: $0.selectedIconPulse) }
731830 self.selected = selected ?? configured.first?.id
@@ -856,6 +955,7 @@ final class CraftChrome: ObservableObject {
856955 tint = light.map { light in dark.map { dark in UIColor { $0.userInterfaceStyle == .dark ? dark : light } } ?? light }
857956 }
858957 if let swipeEnabled = body["swipeEnabled"] as? Bool { tabSwipeEnabled = swipeEnabled }
958 if !body.keys.contains("selection") { tabSelectionCornerRadius = configuredTabSelectionCornerRadius }
859959 withAnimation(Self.motion(.easeOut(duration: 0.2))) { tabBarVisible = !tabs.isEmpty && !hiddenByScreen }
860960 publishLayout()
861961 case "selectTab":
@@ -1312,6 +1412,7 @@ private struct CraftTabSelectionShape: Shape {
13121412}
13131413
13141414private struct CraftTabSelectionIndicator: View {
1415 @ObservedObject private var chrome = CraftChrome.shared
13151416 @ObservedObject private var progress = CraftChrome.shared.tabSwipeProgress
13161417 let selected: Int?
13171418 let count: Int
@@ -1323,7 +1424,7 @@ private struct CraftTabSelectionIndicator: View {
13231424 let width = geometry.size.width / CGFloat(max(1, count))
13241425 CraftTabSelectionShape(cornerRadius: cornerRadius)
13251426 .frame(width: width, height: geometry.size.height)
1326 .foregroundStyle(Color.primary.opacity(0.09))
1427 .foregroundStyle(chrome.tabSelectionColor.map { Color($0) } ?? Color.primary.opacity(0.09))
13271428 .offset(x: position * width)
13281429 }
13291430 }
@@ -1557,6 +1658,8 @@ class DeepLinkManager {
15571658// MARK: - App State
15581659class AppState: ObservableObject {
15591660 @Published var config: CraftConfig
1661 @Published private(set) var appearanceScheme: ColorScheme?
1662 private var appearanceObserver: NSObjectProtocol?
15601663
15611664 init() {
15621665 if let configURL = Bundle.main.url(forResource: "craft.config", withExtension: "json"),
@@ -1565,11 +1668,26 @@ class AppState: ObservableObject {
15651668 } else {
15661669 self.config = CraftConfig()
15671670 }
1671 self.appearanceScheme = CraftInterfaceStyleController.colorScheme(for: config)
1672 appearanceObserver = NotificationCenter.default.addObserver(
1673 forName: .craftColorSchemeChanged,
1674 object: nil,
1675 queue: .main,
1676 ) { [weak self] _ in
1677 guard let self else { return }
1678 self.appearanceScheme = CraftInterfaceStyleController.colorScheme(for: self.config)
1679 }
15681680 // Before the first frame: the hybrid start screen, and the tab bar
15691681 // from the config, are decided here, if the app delegate has not
15701682 // already (it does at launch; the first call decides).
15711683 CraftHybrid.shared.configure(config)
15721684 }
1685
1686 deinit {
1687 if let appearanceObserver {
1688 NotificationCenter.default.removeObserver(appearanceObserver)
1689 }
1690 }
15731691}
15741692
15751693extension CraftConfig {
@@ -1706,6 +1824,9 @@ struct CraftConfig: Codable {
17061824 var nativeScreens: [String: String]? = nil
17071825 /// The tab bar to show before the page describes its own.
17081826 var tabs: [Tab]? = nil
1827 /// Static styling for the selected tab indicator; the page may still
1828 /// override its shape through `tabBar.set({ selection })`.
1829 var tabBar: TabBar? = nil
17091830 /// Hybrid: web-storage keys the page's own code writes, mirrored into the
17101831 /// Keychain under another name for native screens (`{"auth_token": "auth.token"}`).
17111832 var shareStorage: [String: String]? = nil
@@ -1733,6 +1854,15 @@ struct CraftConfig: Codable {
17331854 var selectedIconPulse: Bool? = nil
17341855 }
17351856
1857 struct TabBar: Codable {
1858 var selection: TabSelection? = nil
1859 }
1860
1861 struct TabSelection: Codable {
1862 var cornerRadius: Double? = nil
1863 var color: String? = nil
1864 }
1865
17361866 struct BackgroundRefresh: Codable {
17371867 var enabled: Bool = false
17381868 /// The BGTaskScheduler identifier; `<bundleId>.refresh` if unset.
@@ -2535,6 +2665,18 @@ struct CraftWebView: UIViewRepresentable {
25352665 resolveCallback(callbackId, result: CraftSymbols.image(named: body["name"] as? String ?? "", options: body) ?? NSNull())
25362666 case "statusBarSetStyle":
25372667 resolveCallback(callbackId, result: CraftStatusBar.setStyle(body["style"] as? String ?? "default"))
2668 case "setAppearance":
2669 guard let appearance = body["appearance"] as? String else {
2670 rejectCallback(callbackId, error: "setAppearance needs light, dark or system", code: "INVALID_ARGUMENT")
2671 return
2672 }
2673 guard CraftInterfaceStyleController.set(appearance) else {
2674 rejectCallback(callbackId, error: "setAppearance needs light, dark or system", code: "INVALID_ARGUMENT")
2675 return
2676 }
2677 resolveCallback(callbackId, result: true)
2678 case "getAppearance":
2679 resolveCallback(callbackId, result: CraftInterfaceStyleController.currentName())
25382680 case "chromeSetUnderPageColor":
25392681 // What shows past the page's edges when it rubber-bands.
25402682 if #available(iOS 15.0, *), let webView, let css = body["color"] as? String, let color = UIColor(css: css) {
@@ -5596,6 +5738,14 @@ struct CraftWebView: UIViewRepresentable {
55965738 getInfo: function() { return craft.getDeviceInfo(); },
55975739 getCapabilities: function() { return Promise.resolve(Object.assign({}, craft.capabilities)); }
55985740 };
5741 craft.app = Object.assign(craft.app || {}, {
5742 setAppearance: function(appearance) {
5743 return craft._invoke('setAppearance', {appearance: appearance});
5744 },
5745 getAppearance: function() {
5746 return craft._invoke('getAppearance', {});
5747 }
5748 });
55995749 // Feedback, as on Android and the web, where there is no
56005750 // motor to fire: an app that left enableHaptics off gets
56015751 // nothing played and a settled promise, so `await
packages/ios/templates/CraftHybrid.swiftmodified+1-1
Changes to packages/ios/templates/CraftHybrid.swift
@@ -380,7 +380,7 @@ final class CraftHybrid: NSObject {
380380 }
381381 startsNative = isEnabled && routes.match(startPath) != nil
382382 // The splash waits for a page; an all-native app's first screen draws itself.
383 CraftChrome.shared.configure(tabs: config.tabs ?? [], selected: tabIds(from: config.tabs ?? []).isEmpty ? nil : CraftHybridRoutes.tab(owning: startPath, among: tabIds(from: config.tabs ?? [])), splash: hasWeb && !startsNative)
383 CraftChrome.shared.configure(tabs: config.tabs ?? [], selected: tabIds(from: config.tabs ?? []).isEmpty ? nil : CraftHybridRoutes.tab(owning: startPath, among: tabIds(from: config.tabs ?? [])), splash: hasWeb && !startsNative, appearance: config.tabBar)
384384
385385 let script = config.nativeApp.flatMap { $0.isEmpty ? nil : $0 }
386386 appScriptRoute = script != nil && Self.hasBundle && (isEnabled || !hasWeb) ? script : nil
packages/ios/templates/CraftNativeScreen.swiftmodified+28-0
Changes to packages/ios/templates/CraftNativeScreen.swift
@@ -5545,6 +5545,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
55455545
55465546 override func viewDidLoad() {
55475547 super.viewDidLoad()
5548 CraftInterfaceStyleController.apply(to: self, config: config)
55485549 navigationItem.title = routeName ?? config.appName
55495550 view.backgroundColor = config.resolvedBackgroundColor
55505551 rootStack.axis = .vertical
@@ -6250,6 +6251,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
62506251 let secureGet: @convention(block) (String) -> String? = { key in
62516252 config.enableSecureStorage ? CraftNativeActions.webSecureValue(forKey: key) : nil
62526253 }
6254 let setAppearance: @convention(block) (String) -> Bool = { value in
6255 if Thread.isMainThread { return CraftInterfaceStyleController.set(value) }
6256 var accepted = false
6257 DispatchQueue.main.sync { accepted = CraftInterfaceStyleController.set(value) }
6258 return accepted
6259 }
6260 let getAppearance: @convention(block) () -> String = {
6261 if Thread.isMainThread { return CraftInterfaceStyleController.currentName() }
6262 var value = "light"
6263 DispatchQueue.main.sync { value = CraftInterfaceStyleController.currentName() }
6264 return value
6265 }
62536266 let setOptions: @convention(block) (String) -> Void = { [weak self] json in
62546267 guard let data = json.data(using: .utf8),
62556268 let options = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
@@ -6262,6 +6275,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
62626275 jsContext.setObject(snapshotSet, forKeyedSubscript: "craftNativeSnapshotSet" as NSString)
62636276 jsContext.setObject(snapshotClear, forKeyedSubscript: "craftNativeSnapshotClear" as NSString)
62646277 jsContext.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
6278 jsContext.setObject(setAppearance, forKeyedSubscript: "craftNativeSetAppearance" as NSString)
6279 jsContext.setObject(getAppearance, forKeyedSubscript: "craftNativeGetAppearance" as NSString)
62656280 jsContext.setObject(setOptions, forKeyedSubscript: "craftNativeSetOptions" as NSString)
62666281 // Older native bundles call the page-shaped `craft.dialog` API
62676282 // directly. Keep that API backed by the same capability protocol as
@@ -6504,6 +6519,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
65046519 return value === undefined ? null : value;
65056520 }
65066521 });
6522 keep('app', {
6523 setAppearance: function(value) {
6524 value = String(value);
6525 if (value !== 'light' && value !== 'dark' && value !== 'system') {
6526 return Promise.reject(new TypeError('craft.app.setAppearance needs light, dark or system'));
6527 }
6528 if (!craftNativeSetAppearance(value)) {
6529 return Promise.reject(new Error('craft.app.setAppearance was rejected'));
6530 }
6531 return Promise.resolve();
6532 },
6533 getAppearance: function() { return Promise.resolve(craftNativeGetAppearance()); }
6534 });
65076535 // The signed-in account (CraftSession): the token under
65086536 // `sessionTokenKey`, and each change of it, which the host
65096537 // reports through `__craftSessionChanged` after the Keychain
packages/ios/templates/CraftWatchApp.swift.templatemodified+4-0
Changes to packages/ios/templates/CraftWatchApp.swift.template
@@ -117,6 +117,10 @@ final class CraftWatchSession: NSObject, ObservableObject {
117117 }
118118
119119 private func apply(_ payload: [String: Any]) {
120 if let incomingVersion = payload["stateVersion"] as? Int {
121 guard incomingVersion >= stateVersion else { return }
122 stateVersion = incomingVersion
123 }
120124 if let plan = payload["plan"] as? [String: Any], payload["type"] as? String == "planned-session" {
121125 plannedSession = plan
122126 status = "Plan ready"
packages/typescript/src/__tests__/bridge-integration.test.tsmodified+8-0
Changes to packages/typescript/src/__tests__/bridge-integration.test.ts
@@ -35,6 +35,8 @@ describe('Bridge Type Definitions', () => {
3535 version: '1.0.0',
3636 platform: 'macos',
3737 }),
38 setAppearance: async (_appearance: 'light' | 'dark' | 'system') => {},
39 getAppearance: async () => 'light',
3840 },
3941 }
4042
@@ -87,6 +89,8 @@ describe('Bridge Type Definitions', () => {
8789 version: '1.0.0',
8890 platform: 'macos',
8991 }),
92 setAppearance: async (_appearance: 'light' | 'dark' | 'system') => {},
93 getAppearance: async () => 'light',
9094 }
9195
9296 expect(typeof mockApp.hideDockIcon).toBe('function')
@@ -292,6 +296,8 @@ describe('Bridge API Method Signatures', () => {
292296 version: '1.0.0',
293297 platform: 'macos',
294298 }),
299 setAppearance: async (_appearance: 'light' | 'dark' | 'system') => {},
300 getAppearance: async () => 'light',
295301 }
296302
297303 const info = await mockApp.getInfo()
@@ -334,6 +340,8 @@ describe('Window Global Type Augmentation', () => {
334340 version: '1.0.0',
335341 platform: 'macos',
336342 }),
343 setAppearance: async (_appearance: 'light' | 'dark' | 'system') => {},
344 getAppearance: async () => 'light',
337345 },
338346 } as CraftBridgeAPI,
339347 }
packages/typescript/src/api/app.tsmodified+2-2
Changes to packages/typescript/src/api/app.ts
@@ -629,14 +629,14 @@ class AppManager {
629629 // ==========================================================================
630630
631631 /**
632 * Set app appearance (macOS)
632 * Set the app appearance on native hosts.
633633 */
634634 async setAppearance(appearance: 'light' | 'dark' | 'system'): Promise<void> {
635635 await this._call('setAppearance', { appearance })
636636 }
637637
638638 /**
639 * Get current appearance
639 * Get the current effective Light/Dark appearance.
640640 */
641641 async getAppearance(): Promise<'light' | 'dark'> {
642642 return this._call('getAppearance')
packages/typescript/src/types.tsmodified+6-0
Changes to packages/typescript/src/types.ts
@@ -761,6 +761,12 @@ export interface CraftAppAPI {
761761 * Get application information
762762 */
763763 getInfo(): Promise<AppInfo>
764
765 /** Pin every native surface to Light, Dark, or the system setting. */
766 setAppearance(appearance: 'light' | 'dark' | 'system'): Promise<void>
767
768 /** Read the app's effective Light/Dark appearance. */
769 getAppearance(): Promise<'light' | 'dark'>
764770}
765771
766772/**
packages/zig/src/ios_config.zigmodified+20-3
Changes to packages/zig/src/ios_config.zig
@@ -185,7 +185,7 @@ const number_keys = [_][]const u8{ "splashMaxSeconds", "requestTimeoutSeconds" }
185185/// value inside it throws; only a null at the top level is dropped.
186186const optional_string_map_keys = [_][]const u8{ "nativeScreens", "shareStorage" };
187187/// Swift `Codable` structs, whose fields are checked by `checkObject`.
188const optional_object_keys = [_]Nested{ .{ .key = "backgroundRefresh", .fields = &background_refresh_fields }, .{ .key = "crashReporting", .fields = &crash_reporting_fields } };
188const optional_object_keys = [_]Nested{ .{ .key = "backgroundRefresh", .fields = &background_refresh_fields }, .{ .key = "crashReporting", .fields = &crash_reporting_fields }, .{ .key = "tabBar", .fields = &tab_bar_fields } };
189189/// Swift arrays of `Codable` structs. Every element is checked.
190190const optional_array_of_object_keys = [_]Nested{ .{ .key = "tabs", .fields = &tab_fields }, .{ .key = "appShortcuts", .fields = &app_shortcut_fields } };
191191
@@ -199,8 +199,8 @@ const Nested = struct { key: []const u8, fields: []const Field };
199199/// ignores a property's initial value, so `BackgroundRefresh.enabled`, which
200200/// defaults to false, still throws when it is missing or null. An optional
201201/// field may be missing or null. Both checked against `swiftc`.
202const Field = struct { name: []const u8, kind: Kind, required: bool };
203const Kind = enum { string, bool, number, string_array };
202const Field = struct { name: []const u8, kind: Kind, required: bool, object_fields: []const Field = &.{} };
203const Kind = enum { string, bool, number, string_array, object };
204204
205205/// `CraftConfig.BackgroundRefresh`.
206206const background_refresh_fields = [_]Field{
@@ -219,6 +219,15 @@ const crash_reporting_fields = [_]Field{
219219 .{ .name = "sessions", .kind = .bool, .required = false },
220220};
221221
222/// `CraftConfig.TabBar` and its optional selected-indicator styling.
223const tab_selection_fields = [_]Field{
224 .{ .name = "cornerRadius", .kind = .number, .required = false },
225 .{ .name = "color", .kind = .string, .required = false },
226};
227const tab_bar_fields = [_]Field{
228 .{ .name = "selection", .kind = .object, .required = false, .object_fields = &tab_selection_fields },
229};
230
222231/// `CraftConfig.Tab`.
223232const tab_fields = [_]Field{
224233 .{ .name = "id", .kind = .string, .required = true },
@@ -260,6 +269,7 @@ fn isKind(v: std.json.Value, kind: Kind) bool {
260269 .bool => v == .bool,
261270 .number => isNumber(v),
262271 .string_array => isStringArray(v),
272 .object => v == .object,
263273 };
264274}
265275
@@ -277,6 +287,7 @@ fn checkObject(v: std.json.Value, fields: []const Field) bool {
277287 continue;
278288 }
279289 if (!isKind(present, field.kind)) return false;
290 if (field.kind == .object and !checkObject(present, field.object_fields)) return false;
280291 }
281292 return true;
282293}
@@ -840,6 +851,9 @@ test "nested types decode field by field, defaults or not" {
840851 "\"tabs\":[{\"id\":\"health\",\"title\":\"Health\",\"selectedSymbol\":null,\"selectedIconTint\":null,\"selectedIconPulse\":null}]",
841852 "\"crashReporting\":{\"key\":\"bughq_k\"}",
842853 "\"crashReporting\":{\"key\":\"bughq_k\",\"host\":\"https://bughq.org\",\"environment\":null,\"sessions\":false}",
854 "\"tabBar\":{}",
855 "\"tabBar\":{\"selection\":{\"cornerRadius\":8,\"color\":\"#123456\"}}",
856 "\"tabBar\":{\"selection\":null}",
843857 }) |good| {
844858 const json = try completeConfig(testing.allocator, good);
845859 defer testing.allocator.free(json);
@@ -862,6 +876,9 @@ test "nested types decode field by field, defaults or not" {
862876 "\"tabs\":[{\"id\":\"a\",\"title\":\"A\",\"selectedIconPulse\":\"false\"}]",
863877 "\"tabs\":[null]",
864878 "\"tabs\":{}",
879 "\"tabBar\":[]",
880 "\"tabBar\":{\"selection\":{\"cornerRadius\":\"8\"}}",
881 "\"tabBar\":{\"selection\":{\"color\":42}}",
865882 }) |bad| {
866883 const json = try completeConfig(testing.allocator, bad);
867884 defer testing.allocator.free(json);
packages/zig/src/macos.zigmodified+68-0
Changes to packages/zig/src/macos.zig
@@ -6509,10 +6509,66 @@ fn handleCreateWebView(
65096509 return null;
65106510}
65116511
6512/// `webView:runOpenPanelWithParameters:initiatedByFrame:completionHandler:`
6513///
6514/// WKWebView does not present an `NSOpenPanel` for an HTML file input on its
6515/// own. Without this delegate callback the input is inert, including the
6516/// hidden-input-and-click pattern used by upload controls. Keep the panel
6517/// synchronous, like Craft's other AppKit file dialogs, so the completion
6518/// block remains valid until WebKit receives the selected URLs (or nil on
6519/// cancellation).
6520fn handleOpenPanel(
6521 _: objc.id,
6522 _: objc.SEL,
6523 webView: objc.id,
6524 parameters: objc.id,
6525 frame: objc.id,
6526 completionHandler: objc.id,
6527) callconv(.c) void {
6528 _ = webView;
6529 _ = frame;
6530
6531 const handler = completionHandler orelse return;
6532
6533 const panel = msgSend0(getClass("NSOpenPanel"), "openPanel");
6534 if (panel == null) {
6535 const BlockLayout = extern struct {
6536 isa: ?*anyopaque,
6537 flags: c_int,
6538 reserved: c_int,
6539 invoke: *const fn (*anyopaque, objc.id) callconv(.c) void,
6540 };
6541 const block: *BlockLayout = @ptrCast(@alignCast(handler));
6542 block.invoke(handler, null);
6543 return;
6544 }
6545
6546 _ = msgSend1Bool(panel, "setCanChooseFiles:", true);
6547 _ = msgSend1Bool(panel, "setCanChooseDirectories:", parameters != null and msgSendBool(parameters, "allowsDirectories"));
6548 _ = msgSend1Bool(panel, "setAllowsMultipleSelection:", parameters != null and msgSendBool(parameters, "allowsMultipleSelection"));
6549
6550 if (parameters != null and msgSendBool1Sel(parameters, "respondsToSelector:", sel("allowedFileExtensions"))) {
6551 const extensions = msgSend0(parameters, "allowedFileExtensions");
6552 if (extensions != null) _ = msgSend1(panel, "setAllowedFileTypes:", extensions);
6553 }
6554
6555 const response = msgSend0(panel, "runModal");
6556 const urls: objc.id = if (@intFromPtr(response) == 1) msgSend0(panel, "URLs") else null;
6557 const BlockLayout = extern struct {
6558 isa: ?*anyopaque,
6559 flags: c_int,
6560 reserved: c_int,
6561 invoke: *const fn (*anyopaque, objc.id) callconv(.c) void,
6562 };
6563 const block: *BlockLayout = @ptrCast(@alignCast(handler));
6564 block.invoke(handler, urls);
6565}
6566
65126567/// Attach craft's `WKUIDelegate`.
65136568///
65146569/// Two callbacks, neither of which has anything to do with the other:
65156570/// `getUserMedia()` permission, and the page asking for a second window.
6571/// The third callback supplies the AppKit panel behind HTML file inputs.
65166572///
65176573/// Installed in every non-benchmark window. It used to be installed only under
65186574/// `--dev-tools`, which meant a packaged app was denied the camera outright
@@ -6563,6 +6619,18 @@ pub fn setupUIDelegate(webview: objc.id) !void {
65636619 }
65646620 }
65656621
6622 // HTML `<input type="file">`. The completion block receives an
6623 // NSArray<NSURL *> or nil when the person cancels the panel.
6624 if (!objc.class_addMethod(
6625 @ptrCast(@alignCast(delegateClass)),
6626 objc.sel_registerName("webView:runOpenPanelWithParameters:initiatedByFrame:completionHandler:"),
6627 @as(objc.IMP, @ptrCast(@constCast(&handleOpenPanel))),
6628 "v@:@@@?",
6629 )) {
6630 if (comptime std.ascii.eqlIgnoreCase(@tagName(builtin.mode), "debug"))
6631 std.debug.print("[UI] Failed to add file open panel method\n", .{});
6632 }
6633
65666634 // `window.open()` / `target="_blank"`. Returns an object (the child
65676635 // webview, or nil): @ = id return, self, _cmd, webView, configuration,
65686636 // navigationAction, windowFeatures.
packages/zig/test/ios_conformance_test.zigmodified+8-2
Changes to packages/zig/test/ios_conformance_test.zig
@@ -201,12 +201,16 @@ const dispatch_end = "func webView(";
201201/// and HKLiveWorkoutBuilder are owned by CraftHealth.swift, including their
202202/// delegate callbacks and iOS 26 availability; the actions remain on the
203203/// Swift shim until the Zig bridge has an equivalent lifecycle channel.
204/// 61 when native screens gained setAppearance and getAppearance. They update
205/// the app-wide UIKit style and remain on the Swift side until Zig owns that
206/// lifecycle as well; each new action increases the outstanding count, and no
207/// existing Zig action was removed.
204208/// 59 when the spec gained getHealthSamples: glucose, insulin and carbs one
205209/// sample at a time (bloodGlucose, insulinDelivery, dietaryCarbohydrates), an
206210/// HKSampleQuery in CraftHealth.swift like the other health reads. It went
207211/// into Swift first and falls through to the shim, which serves it. Not a
208212/// regression: no action left Zig.
209const max_not_yet_migrated: usize = 59;
213const max_not_yet_migrated: usize = 61;
210214
211215fn dispatcherRegion() []const u8 {
212216 // CraftApp.swift also contains chrome, console and bridge-relay handlers.
@@ -1123,13 +1127,15 @@ test "Zig validates every field of the nested types Swift decodes, the way Swift
11231127 "bool"
11241128 else if (std.mem.eql(u8, base, "Double"))
11251129 "number"
1130 else if (std.mem.eql(u8, base, "TabSelection"))
1131 "object"
11261132 else {
11271133 std.debug.print("CraftConfig.{s}.{s} is a {s}, which ios_config.zig has no Kind for.\n", .{ struct_name, member.name, base });
11281134 return error.NestedFieldKindUnknown;
11291135 };
11301136
11311137 var expected_buf: [256]u8 = undefined;
1132 const expected = try std.fmt.bufPrint(&expected_buf, ".,", .{
1138 const expected = try std.fmt.bufPrint(&expected_buf, ".{{ .name = \"{s}\", .kind = .{s}, .required = {s}", .{
11331139 member.name, kind, if (optional) "false" else "true",
11341140 });
11351141 if (std.mem.indexOf(u8, zig_fields, expected) == null) {
packages/zig/test/window_lifecycle_test.zigmodified+18-0
Changes to packages/zig/test/window_lifecycle_test.zig
@@ -1003,6 +1003,24 @@ test "webview delegate ownership ends with its webview" {
10031003 try testing.expect(callsFunction(handler_body, "msgSendVoid0(handler, \"release\")"));
10041004}
10051005
1006test "the UI delegate exposes the WebKit file-input panel" {
1007 const setup_start = std.mem.indexOf(u8, macos_source, "pub fn setupUIDelegate(") orelse
1008 return error.WebViewDelegateSetupNotFound;
1009 const setup_body = enclosingFnBody(macos_source, setup_start);
1010 try testing.expect(std.mem.indexOf(u8, setup_body, "webView:runOpenPanelWithParameters:initiatedByFrame:completionHandler:") != null);
1011 try testing.expect(std.mem.indexOf(u8, setup_body, "v@:@@@?") != null);
1012
1013 const panel_start = std.mem.indexOf(u8, macos_source, "fn handleOpenPanel(") orelse
1014 return error.OpenPanelHandlerNotFound;
1015 const panel_body = enclosingFnBody(macos_source, panel_start);
1016 try testing.expect(callsFunction(panel_body, "openPanel"));
1017 try testing.expect(callsFunction(panel_body, "setAllowsMultipleSelection:"));
1018 try testing.expect(callsFunction(panel_body, "setAllowedFileTypes:"));
1019 try testing.expect(callsFunction(panel_body, "runModal"));
1020 try testing.expect(std.mem.indexOf(u8, panel_body, "block.invoke(handler, urls)") != null);
1021 try testing.expect(std.mem.indexOf(u8, panel_body, "else null") != null);
1022}
1023
10061024test "the scan can actually find the constructors it claims to check" {
10071025 // Guards the guard: every test above depends on `enclosingFnBody` locating
10081026 // real function bodies. If the file's formatting changes such that it