also looking at this
feat: finish native customization and platform seams
#401
21 files
+707
-17
| @@ -1052,6 +1052,36 @@ all: no `WKWebView` is ever created. | ||
| 1052 | 1052 | capabilities whenever `enableGeolocation` / `enableAppleMusic` are set. The |
| 1053 | 1053 | single-screen native app (no `nativeScreens`) gets them as well. |
| 1054 | 1054 | |
| 1055 | ### Generated project customization | |
| 1056 | ||
| 1057 | Generated native sources are owned by Craft and may be replaced on the next | |
| 1058 | build. Use typed configuration for common chrome changes, and declare a hook | |
| 1059 | only 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 | ||
| 1073 | The `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`. | |
| 1075 | Omitted values preserve each host's default selection appearance. | |
| 1076 | ||
| 1077 | `postGenerate` runs after `craft ios build` or `craft android build` generates | |
| 1078 | the native project. The command runs with the generated project as its working | |
| 1079 | directory and is launched directly from `command` plus `args` (no shell | |
| 1080 | interpolation). Craft provides `CRAFT_PROJECT_ROOT`, `CRAFT_PLATFORM`, and | |
| 1081 | `CRAFT_GENERATE_PHASE=build`. Missing executables, malformed options, and | |
| 1082 | non-zero exits fail the build and include the hook's stderr, so a source patch | |
| 1083 | cannot silently stop applying after a template change. | |
| 1084 | ||
| 1055 | 1085 | ### The app script (`nativeApp`) |
| 1056 | 1086 | |
| 1057 | 1087 | `nativeApp` names a bundle route that has no screen (Stacks names it `__app`). |
| @@ -484,6 +484,29 @@ changes; the other two values pin the generated host. If it is omitted, | ||
| 484 | 484 | `darkMode` remains the compatibility switch (`false` pins light, `true` pins |
| 485 | 485 | dark). |
| 486 | 486 | |
| 487 | Native tab-bar selection styling is opt-in and lives in the generated config, | |
| 488 | so 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 | |
| 503 | its 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 | |
| 508 | build and includes the hook's stderr in the diagnostic. | |
| 509 | ||
| 487 | 510 | Remote app and trusted-origin URLs must use HTTPS. Local development may use |
| 488 | 511 | HTTP with `localhost`, `127.0.0.1`, or the Android emulator host `10.0.2.2`. |
| 489 | 512 | Origins are normalized before they are stored, and a configured development |
| @@ -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,56 @@ 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('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 | ||
| 18 | 68 | it('bundles custom fonts with their licenses and removes retired fonts', async () => { |
| 19 | 69 | const root = createTempDir('craft-native-fonts-') |
| 20 | 70 | const font = join(root, 'Signature.ttf') |
| @@ -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 |
| @@ -86,6 +86,11 @@ internal data class CraftNativeTab( | ||
| 86 | 86 | val selectedSymbol: String? = null, val selectedIconTint: String? = null, val selectedIconPulse: Boolean? = null, |
| 87 | 87 | ) |
| 88 | 88 | |
| 89 | internal data class CraftNativeTabBarAppearance( | |
| 90 | val selectionColor: String? = null, | |
| 91 | val selectionCornerRadius: Float? = null, | |
| 92 | ) | |
| 93 | ||
| 89 | 94 | internal data class CraftTabIconAppearance(val symbol: String, val tint: Int?, val pulse: Boolean) |
| 90 | 95 | |
| 91 | 96 | /** The app's paths (`nativeScreens`), its tabs and its site. */ |
| @@ -122,6 +127,16 @@ internal class CraftNativeRoutes(config: JSONObject) { | ||
| 122 | 127 | } |
| 123 | 128 | } |
| 124 | 129 | |
| 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 | ||
| 125 | 140 | /** The app's site (`devServerURL`): its origin answers paths no screen draws. */ |
| 126 | 141 | val site: Uri? = config.optString("devServerURL").takeIf { it.isNotBlank() }?.let(Uri::parse) |
| 127 | 142 | val siteOrigin: String? = site?.let { uri -> |
| @@ -755,7 +770,11 @@ internal object CraftNativePrelude { | ||
| 755 | 770 | * `craft.navigation.setOptions` (title, largeTitle, hidden, rightButtons, |
| 756 | 771 | * tabAccessory, tabBarHidden); the tabs come from the app's `tabs`. |
| 757 | 772 | */ |
| 758 | internal class CraftNativeChrome(context: Context, private val tabs: List<CraftNativeTab>) : LinearLayout(context) { | |
| 773 | internal class CraftNativeChrome( | |
| 774 | context: Context, | |
| 775 | private val tabs: List<CraftNativeTab>, | |
| 776 | private val tabBarAppearance: CraftNativeTabBarAppearance = CraftNativeTabBarAppearance(), | |
| 777 | ) : LinearLayout(context) { | |
| 759 | 778 | val content = FrameLayout(context) |
| 760 | 779 | private val bar = LinearLayout(context) |
| 761 | 780 | private val backButton = ImageView(context) |
| @@ -1099,6 +1118,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN | ||
| 1099 | 1118 | accessorySpec?.let { accessoryIcon.setImageDrawable(CraftNativeIcons.drawable(context, null, it.optString("symbol"), text)) } |
| 1100 | 1119 | tabViews.forEachIndexed { index, (icon, label) -> |
| 1101 | 1120 | tabBar.getChildAt(index).isSelected = index == selectedTab |
| 1121 | tabBar.getChildAt(index).background = tabCellBackground(index, dark) | |
| 1102 | 1122 | val color = if (index == selectedTab) accent else muted |
| 1103 | 1123 | val id = tabs[index].id |
| 1104 | 1124 | val number = tabIconText[id] |
| @@ -1188,6 +1208,17 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN | ||
| 1188 | 1208 | |
| 1189 | 1209 | private fun accentColor(dark: Boolean): Int = if (dark) Color.parseColor("#0A84FF") else Color.parseColor("#007AFF") |
| 1190 | 1210 | |
| 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 | ||
| 1191 | 1222 | private fun rippleBackground(): android.graphics.drawable.Drawable? { |
| 1192 | 1223 | val value = TypedValue() |
| 1193 | 1224 | return if (context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless, value, true)) { |
| @@ -4198,7 +4198,7 @@ class MainActivity : AppCompatActivity() { | ||
| 4198 | 4198 | } |
| 4199 | 4199 | |
| 4200 | 4200 | private fun installChrome() { |
| 4201 | val bars = CraftNativeChrome(this, routes.tabs) | |
| 4201 | val bars = CraftNativeChrome(this, routes.tabs, routes.tabBarAppearance) | |
| 4202 | 4202 | bars.layoutParams = FrameLayout.LayoutParams(-1, -1) |
| 4203 | 4203 | bars.onBack = { onBackPressedDispatcherCompat() } |
| 4204 | 4204 | bars.onAccessory = { path -> navigateOpen(path) } |
| @@ -391,6 +391,29 @@ Appearance and navigation: | ||
| 391 | 391 | - `swipeNavigation`: an edge swipe goes back and forward through the page's |
| 392 | 392 | history, pushState entries included. |
| 393 | 393 | |
| 394 | Native tab-bar selection styling is opt-in and lives in the generated config, | |
| 395 | so 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 | |
| 410 | keep its host default. `postGenerate` runs after `craft ios build` has written | |
| 411 | the Xcode project, with the generated project as its working directory. Craft | |
| 412 | does not invoke a shell: `command` and `args` are passed directly. The hook | |
| 413 | receives `CRAFT_PROJECT_ROOT`, `CRAFT_PLATFORM=ios`, and | |
| 414 | `CRAFT_GENERATE_PHASE=build`. A missing executable, non-zero exit, or invalid | |
| 415 | configuration fails the build with the hook's stderr in the diagnostic. | |
| 416 | ||
| 394 | 417 | Debug builds let Safari's Develop menu inspect the webview, and relay the |
| 395 | 418 | page's console, uncaught errors, rejected promises and failed script loads to |
| 396 | 419 | the device log as `[craft page] …`, so a page that only breaks inside the app |
| @@ -17,6 +17,24 @@ final class NativeLayoutUnitTests: XCTestCase { | ||
| 17 | 17 | return controller |
| 18 | 18 | } |
| 19 | 19 | |
| 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 | ||
| 20 | 38 | func testReturningToAnUnchangedThemeRetainsTextAndScrollGeometry() throws { |
| 21 | 39 | let rows: [[String: Any]] = (0..<100).map { index in |
| 22 | 40 | ["id": "row-\(index)", "type": "Text", "props": ["testID": "row-\(index)"], |
| @@ -1,4 +1,4 @@ | ||
| 1 | import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' | |
| 1 | import { chmodSync, 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,68 @@ 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('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 | ||
| 41 | 104 | it('restores the shell with the actual Xcode Cloud build identity without changing other resources', () => { |
| 42 | 105 | const output = createTempDir('craft-cloud-identity-') |
| 43 | 106 | const file = join(output, 'craft.config.json') |
| @@ -799,7 +862,7 @@ describe('Craft iOS builder', () => { | ||
| 799 | 862 | const launch = JSON.parse(readFileSync(join(output, 'Assets.xcassets', 'LaunchBackground.colorset', 'Contents.json'), 'utf8')) |
| 800 | 863 | expect(plist).toContain('<key>UIUserInterfaceStyle</key>\n <string>Automatic</string>') |
| 801 | 864 | expect(plist).toContain('<string>UIStatusBarStyleDefault</string>') |
| 802 | expect(swift).toContain('.preferredColorScheme(appState.config.colorScheme)') | |
| 865 | expect(swift).toContain('.preferredColorScheme(appState.appearanceScheme)') | |
| 803 | 866 | expect(launch.colors).toHaveLength(2) |
| 804 | 867 | expect(launch.colors[1].appearances).toEqual([{ appearance: 'luminosity', value: 'dark' }]) |
| 805 | 868 | expect(launch.colors[1].color.components.red).toBe('0.008') |
| @@ -830,6 +893,27 @@ describe('Craft iOS builder', () => { | ||
| 830 | 893 | expect(source).toContain('renderCommitted(document)') |
| 831 | 894 | }) |
| 832 | 895 | |
| 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 | ||
| 833 | 917 | it('emits native layout measurements only when a view changes frame', async () => { |
| 834 | 918 | const output = createTempDir('craft-ios-native-layout-') |
| 835 | 919 | await init({ |
| @@ -2567,6 +2651,8 @@ describe('Craft iOS builder', () => { | ||
| 2567 | 2651 | expect(watch).toContain('protocolVersion') |
| 2568 | 2652 | expect(watch).toContain('requestId') |
| 2569 | 2653 | expect(watch).toContain('stateVersion') |
| 2654 | expect(watch).toContain('guard incomingVersion >= stateVersion else { return }') | |
| 2655 | expect(watch).toContain('stateVersion = incomingVersion') | |
| 2570 | 2656 | expect(watch).toContain('planned-session') |
| 2571 | 2657 | expect(watch).toContain('craft.watch.state.v1') |
| 2572 | 2658 | expect(watch).toContain('updateApplicationContext(state)') |
| @@ -68,6 +68,21 @@ 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 | ||
| 77 | export 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 | ||
| 71 | 86 | export interface CraftConfig { |
| 72 | 87 | appShortcuts?: CraftAppShortcut[] |
| 73 | 88 | /** Bundled font files keyed by their PostScript font name, used as native fontFamily. */ |
| @@ -86,6 +101,10 @@ export interface CraftConfig { | ||
| 86 | 101 | * and `dark` pin one. Unset keeps the older `darkMode` switch. |
| 87 | 102 | */ |
| 88 | 103 | 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 | |
| 89 | 108 | backgroundColor?: string |
| 90 | 109 | /** The launch and webview background while the phone is in Dark Mode. */ |
| 91 | 110 | backgroundColorDark?: string |
| @@ -639,6 +658,42 @@ export interface BuildOptions { | ||
| 639 | 658 | runtimeDir?: string | null |
| 640 | 659 | } |
| 641 | 660 | |
| 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 | */ | |
| 667 | export 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 | ||
| 642 | 697 | export interface OpenOptions { |
| 643 | 698 | output: string |
| 644 | 699 | } |
| @@ -1502,6 +1557,11 @@ export async function init(options: InitOptions): Promise<void> { | ||
| 1502 | 1557 | if (config.renderer !== 'web' && config.renderer !== 'native') { |
| 1503 | 1558 | throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`) |
| 1504 | 1559 | } |
| 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') | |
| 1505 | 1565 | if (config.enableBackgroundLocation) config.enableGeolocation = true |
| 1506 | 1566 | const problems = hybridConfigProblems(config) |
| 1507 | 1567 | if (problems.length) throw new Error(problems.join('\n')) |
| @@ -1755,6 +1815,8 @@ catch (error) { | ||
| 1755 | 1815 | }) |
| 1756 | 1816 | } |
| 1757 | 1817 | |
| 1818 | runPostGenerateHook(config.postGenerate, output) | |
| 1819 | ||
| 1758 | 1820 | console.log('') |
| 1759 | 1821 | } |
| 1760 | 1822 | |
| @@ -102,6 +102,96 @@ enum CraftPageAppearance { | ||
| 102 | 102 | } |
| 103 | 103 | } |
| 104 | 104 | |
| 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. | |
| 111 | enum 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 | ||
| 105 | 195 | /// Tells the shell when SwiftUI's colour scheme changes: the one appearance |
| 106 | 196 | /// setting with no notification of its own. |
| 107 | 197 | struct CraftColorSchemeObserver: View { |
| @@ -195,6 +285,7 @@ struct CraftRecoveryBudget { | ||
| 195 | 285 | final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { |
| 196 | 286 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { |
| 197 | 287 | let config = CraftConfig.bundled() |
| 288 | CraftInterfaceStyleController.configure(config) | |
| 198 | 289 | // First, so a crash anywhere in the rest of launch is reported too. |
| 199 | 290 | CraftCrashReporter.shared.start(config) |
| 200 | 291 | UNUserNotificationCenter.current().delegate = self |
| @@ -606,7 +697,7 @@ struct CraftApp: App { | ||
| 606 | 697 | .ignoresSafeArea(.keyboard, edges: .bottom) |
| 607 | 698 | } |
| 608 | 699 | } |
| 609 | .preferredColorScheme(appState.config.colorScheme) | |
| 700 | .preferredColorScheme(appState.appearanceScheme) | |
| 610 | 701 | .environmentObject(appState) |
| 611 | 702 | .onOpenURL { url in |
| 612 | 703 | // Handle deep links and universal links |
| @@ -689,6 +780,10 @@ final class CraftChrome: ObservableObject { | ||
| 689 | 780 | /// The selected tab highlight's corner radius. Nil preserves the default |
| 690 | 781 | /// capsule, while a finite non-negative value opts into a rounded rect. |
| 691 | 782 | 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? | |
| 692 | 787 | |
| 693 | 788 | weak var webView: WKWebView? |
| 694 | 789 | /// What the tab bar covers at the bottom of the screen, in points. |
| @@ -723,9 +818,13 @@ final class CraftChrome: ObservableObject { | ||
| 723 | 818 | /// The tab bar from `tabs` in craft.config.json, there before the page |
| 724 | 819 | /// loads; the page's own `tabBar` message replaces it later. `splash` |
| 725 | 820 | /// 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) { | |
| 727 | 822 | splashVisible = splash |
| 728 | 823 | 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) } | |
| 729 | 828 | guard !configured.isEmpty else { return } |
| 730 | 829 | 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) } |
| 731 | 830 | self.selected = selected ?? configured.first?.id |
| @@ -856,6 +955,7 @@ final class CraftChrome: ObservableObject { | ||
| 856 | 955 | tint = light.map { light in dark.map { dark in UIColor { $0.userInterfaceStyle == .dark ? dark : light } } ?? light } |
| 857 | 956 | } |
| 858 | 957 | if let swipeEnabled = body["swipeEnabled"] as? Bool { tabSwipeEnabled = swipeEnabled } |
| 958 | if !body.keys.contains("selection") { tabSelectionCornerRadius = configuredTabSelectionCornerRadius } | |
| 859 | 959 | withAnimation(Self.motion(.easeOut(duration: 0.2))) { tabBarVisible = !tabs.isEmpty && !hiddenByScreen } |
| 860 | 960 | publishLayout() |
| 861 | 961 | case "selectTab": |
| @@ -1312,6 +1412,7 @@ private struct CraftTabSelectionShape: Shape { | ||
| 1312 | 1412 | } |
| 1313 | 1413 | |
| 1314 | 1414 | private struct CraftTabSelectionIndicator: View { |
| 1415 | @ObservedObject private var chrome = CraftChrome.shared | |
| 1315 | 1416 | @ObservedObject private var progress = CraftChrome.shared.tabSwipeProgress |
| 1316 | 1417 | let selected: Int? |
| 1317 | 1418 | let count: Int |
| @@ -1323,7 +1424,7 @@ private struct CraftTabSelectionIndicator: View { | ||
| 1323 | 1424 | let width = geometry.size.width / CGFloat(max(1, count)) |
| 1324 | 1425 | CraftTabSelectionShape(cornerRadius: cornerRadius) |
| 1325 | 1426 | .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)) | |
| 1327 | 1428 | .offset(x: position * width) |
| 1328 | 1429 | } |
| 1329 | 1430 | } |
| @@ -1557,6 +1658,8 @@ class DeepLinkManager { | ||
| 1557 | 1658 | // MARK: - App State |
| 1558 | 1659 | class AppState: ObservableObject { |
| 1559 | 1660 | @Published var config: CraftConfig |
| 1661 | @Published private(set) var appearanceScheme: ColorScheme? | |
| 1662 | private var appearanceObserver: NSObjectProtocol? | |
| 1560 | 1663 | |
| 1561 | 1664 | init() { |
| 1562 | 1665 | if let configURL = Bundle.main.url(forResource: "craft.config", withExtension: "json"), |
| @@ -1565,11 +1668,26 @@ class AppState: ObservableObject { | ||
| 1565 | 1668 | } else { |
| 1566 | 1669 | self.config = CraftConfig() |
| 1567 | 1670 | } |
| 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 | } | |
| 1568 | 1680 | // Before the first frame: the hybrid start screen, and the tab bar |
| 1569 | 1681 | // from the config, are decided here, if the app delegate has not |
| 1570 | 1682 | // already (it does at launch; the first call decides). |
| 1571 | 1683 | CraftHybrid.shared.configure(config) |
| 1572 | 1684 | } |
| 1685 | ||
| 1686 | deinit { | |
| 1687 | if let appearanceObserver { | |
| 1688 | NotificationCenter.default.removeObserver(appearanceObserver) | |
| 1689 | } | |
| 1690 | } | |
| 1573 | 1691 | } |
| 1574 | 1692 | |
| 1575 | 1693 | extension CraftConfig { |
| @@ -1706,6 +1824,9 @@ struct CraftConfig: Codable { | ||
| 1706 | 1824 | var nativeScreens: [String: String]? = nil |
| 1707 | 1825 | /// The tab bar to show before the page describes its own. |
| 1708 | 1826 | 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 | |
| 1709 | 1830 | /// Hybrid: web-storage keys the page's own code writes, mirrored into the |
| 1710 | 1831 | /// Keychain under another name for native screens (`{"auth_token": "auth.token"}`). |
| 1711 | 1832 | var shareStorage: [String: String]? = nil |
| @@ -1733,6 +1854,15 @@ struct CraftConfig: Codable { | ||
| 1733 | 1854 | var selectedIconPulse: Bool? = nil |
| 1734 | 1855 | } |
| 1735 | 1856 | |
| 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 | ||
| 1736 | 1866 | struct BackgroundRefresh: Codable { |
| 1737 | 1867 | var enabled: Bool = false |
| 1738 | 1868 | /// The BGTaskScheduler identifier; `<bundleId>.refresh` if unset. |
| @@ -2535,6 +2665,18 @@ struct CraftWebView: UIViewRepresentable { | ||
| 2535 | 2665 | resolveCallback(callbackId, result: CraftSymbols.image(named: body["name"] as? String ?? "", options: body) ?? NSNull()) |
| 2536 | 2666 | case "statusBarSetStyle": |
| 2537 | 2667 | 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()) | |
| 2538 | 2680 | case "chromeSetUnderPageColor": |
| 2539 | 2681 | // What shows past the page's edges when it rubber-bands. |
| 2540 | 2682 | 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 { | ||
| 5596 | 5738 | getInfo: function() { return craft.getDeviceInfo(); }, |
| 5597 | 5739 | getCapabilities: function() { return Promise.resolve(Object.assign({}, craft.capabilities)); } |
| 5598 | 5740 | }; |
| 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 | }); | |
| 5599 | 5749 | // Feedback, as on Android and the web, where there is no |
| 5600 | 5750 | // motor to fire: an app that left enableHaptics off gets |
| 5601 | 5751 | // nothing played and a settled promise, so `await |
| @@ -380,7 +380,7 @@ final class CraftHybrid: NSObject { | ||
| 380 | 380 | } |
| 381 | 381 | startsNative = isEnabled && routes.match(startPath) != nil |
| 382 | 382 | // 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) | |
| 384 | 384 | |
| 385 | 385 | let script = config.nativeApp.flatMap { $0.isEmpty ? nil : $0 } |
| 386 | 386 | appScriptRoute = script != nil && Self.hasBundle && (isEnabled || !hasWeb) ? script : nil |
| @@ -5545,6 +5545,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, | ||
| 5545 | 5545 | |
| 5546 | 5546 | override func viewDidLoad() { |
| 5547 | 5547 | super.viewDidLoad() |
| 5548 | CraftInterfaceStyleController.apply(to: self, config: config) | |
| 5548 | 5549 | navigationItem.title = routeName ?? config.appName |
| 5549 | 5550 | view.backgroundColor = config.resolvedBackgroundColor |
| 5550 | 5551 | rootStack.axis = .vertical |
| @@ -6250,6 +6251,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, | ||
| 6250 | 6251 | let secureGet: @convention(block) (String) -> String? = { key in |
| 6251 | 6252 | config.enableSecureStorage ? CraftNativeActions.webSecureValue(forKey: key) : nil |
| 6252 | 6253 | } |
| 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 | } | |
| 6253 | 6266 | let setOptions: @convention(block) (String) -> Void = { [weak self] json in |
| 6254 | 6267 | guard let data = json.data(using: .utf8), |
| 6255 | 6268 | let options = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } |
| @@ -6262,6 +6275,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, | ||
| 6262 | 6275 | jsContext.setObject(snapshotSet, forKeyedSubscript: "craftNativeSnapshotSet" as NSString) |
| 6263 | 6276 | jsContext.setObject(snapshotClear, forKeyedSubscript: "craftNativeSnapshotClear" as NSString) |
| 6264 | 6277 | jsContext.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString) |
| 6278 | jsContext.setObject(setAppearance, forKeyedSubscript: "craftNativeSetAppearance" as NSString) | |
| 6279 | jsContext.setObject(getAppearance, forKeyedSubscript: "craftNativeGetAppearance" as NSString) | |
| 6265 | 6280 | jsContext.setObject(setOptions, forKeyedSubscript: "craftNativeSetOptions" as NSString) |
| 6266 | 6281 | // Older native bundles call the page-shaped `craft.dialog` API |
| 6267 | 6282 | // directly. Keep that API backed by the same capability protocol as |
| @@ -6504,6 +6519,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, | ||
| 6504 | 6519 | return value === undefined ? null : value; |
| 6505 | 6520 | } |
| 6506 | 6521 | }); |
| 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 | }); | |
| 6507 | 6535 | // The signed-in account (CraftSession): the token under |
| 6508 | 6536 | // `sessionTokenKey`, and each change of it, which the host |
| 6509 | 6537 | // reports through `__craftSessionChanged` after the Keychain |
| @@ -117,6 +117,10 @@ final class CraftWatchSession: NSObject, ObservableObject { | ||
| 117 | 117 | } |
| 118 | 118 | |
| 119 | 119 | private func apply(_ payload: [String: Any]) { |
| 120 | if let incomingVersion = payload["stateVersion"] as? Int { | |
| 121 | guard incomingVersion >= stateVersion else { return } | |
| 122 | stateVersion = incomingVersion | |
| 123 | } | |
| 120 | 124 | if let plan = payload["plan"] as? [String: Any], payload["type"] as? String == "planned-session" { |
| 121 | 125 | plannedSession = plan |
| 122 | 126 | status = "Plan ready" |
| @@ -35,6 +35,8 @@ describe('Bridge Type Definitions', () => { | ||
| 35 | 35 | version: '1.0.0', |
| 36 | 36 | platform: 'macos', |
| 37 | 37 | }), |
| 38 | setAppearance: async (_appearance: 'light' | 'dark' | 'system') => {}, | |
| 39 | getAppearance: async () => 'light', | |
| 38 | 40 | }, |
| 39 | 41 | } |
| 40 | 42 | |
| @@ -87,6 +89,8 @@ describe('Bridge Type Definitions', () => { | ||
| 87 | 89 | version: '1.0.0', |
| 88 | 90 | platform: 'macos', |
| 89 | 91 | }), |
| 92 | setAppearance: async (_appearance: 'light' | 'dark' | 'system') => {}, | |
| 93 | getAppearance: async () => 'light', | |
| 90 | 94 | } |
| 91 | 95 | |
| 92 | 96 | expect(typeof mockApp.hideDockIcon).toBe('function') |
| @@ -292,6 +296,8 @@ describe('Bridge API Method Signatures', () => { | ||
| 292 | 296 | version: '1.0.0', |
| 293 | 297 | platform: 'macos', |
| 294 | 298 | }), |
| 299 | setAppearance: async (_appearance: 'light' | 'dark' | 'system') => {}, | |
| 300 | getAppearance: async () => 'light', | |
| 295 | 301 | } |
| 296 | 302 | |
| 297 | 303 | const info = await mockApp.getInfo() |
| @@ -334,6 +340,8 @@ describe('Window Global Type Augmentation', () => { | ||
| 334 | 340 | version: '1.0.0', |
| 335 | 341 | platform: 'macos', |
| 336 | 342 | }), |
| 343 | setAppearance: async (_appearance: 'light' | 'dark' | 'system') => {}, | |
| 344 | getAppearance: async () => 'light', | |
| 337 | 345 | }, |
| 338 | 346 | } as CraftBridgeAPI, |
| 339 | 347 | } |
| @@ -629,14 +629,14 @@ class AppManager { | ||
| 629 | 629 | // ========================================================================== |
| 630 | 630 | |
| 631 | 631 | /** |
| 632 | * Set app appearance (macOS) | |
| 632 | * Set the app appearance on native hosts. | |
| 633 | 633 | */ |
| 634 | 634 | async setAppearance(appearance: 'light' | 'dark' | 'system'): Promise<void> { |
| 635 | 635 | await this._call('setAppearance', { appearance }) |
| 636 | 636 | } |
| 637 | 637 | |
| 638 | 638 | /** |
| 639 | * Get current appearance | |
| 639 | * Get the current effective Light/Dark appearance. | |
| 640 | 640 | */ |
| 641 | 641 | async getAppearance(): Promise<'light' | 'dark'> { |
| 642 | 642 | return this._call('getAppearance') |
| @@ -761,6 +761,12 @@ export interface CraftAppAPI { | ||
| 761 | 761 | * Get application information |
| 762 | 762 | */ |
| 763 | 763 | 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'> | |
| 764 | 770 | } |
| 765 | 771 | |
| 766 | 772 | /** |
| @@ -185,7 +185,7 @@ const number_keys = [_][]const u8{ "splashMaxSeconds", "requestTimeoutSeconds" } | ||
| 185 | 185 | /// value inside it throws; only a null at the top level is dropped. |
| 186 | 186 | const optional_string_map_keys = [_][]const u8{ "nativeScreens", "shareStorage" }; |
| 187 | 187 | /// Swift `Codable` structs, whose fields are checked by `checkObject`. |
| 188 | const optional_object_keys = [_]Nested{ .{ .key = "backgroundRefresh", .fields = &background_refresh_fields }, .{ .key = "crashReporting", .fields = &crash_reporting_fields } }; | |
| 188 | const optional_object_keys = [_]Nested{ .{ .key = "backgroundRefresh", .fields = &background_refresh_fields }, .{ .key = "crashReporting", .fields = &crash_reporting_fields }, .{ .key = "tabBar", .fields = &tab_bar_fields } }; | |
| 189 | 189 | /// Swift arrays of `Codable` structs. Every element is checked. |
| 190 | 190 | const optional_array_of_object_keys = [_]Nested{ .{ .key = "tabs", .fields = &tab_fields }, .{ .key = "appShortcuts", .fields = &app_shortcut_fields } }; |
| 191 | 191 | |
| @@ -199,8 +199,8 @@ const Nested = struct { key: []const u8, fields: []const Field }; | ||
| 199 | 199 | /// ignores a property's initial value, so `BackgroundRefresh.enabled`, which |
| 200 | 200 | /// defaults to false, still throws when it is missing or null. An optional |
| 201 | 201 | /// field may be missing or null. Both checked against `swiftc`. |
| 202 | const Field = struct { name: []const u8, kind: Kind, required: bool }; | |
| 203 | const Kind = enum { string, bool, number, string_array }; | |
| 202 | const Field = struct { name: []const u8, kind: Kind, required: bool, object_fields: []const Field = &.{} }; | |
| 203 | const Kind = enum { string, bool, number, string_array, object }; | |
| 204 | 204 | |
| 205 | 205 | /// `CraftConfig.BackgroundRefresh`. |
| 206 | 206 | const background_refresh_fields = [_]Field{ |
| @@ -219,6 +219,15 @@ const crash_reporting_fields = [_]Field{ | ||
| 219 | 219 | .{ .name = "sessions", .kind = .bool, .required = false }, |
| 220 | 220 | }; |
| 221 | 221 | |
| 222 | /// `CraftConfig.TabBar` and its optional selected-indicator styling. | |
| 223 | const tab_selection_fields = [_]Field{ | |
| 224 | .{ .name = "cornerRadius", .kind = .number, .required = false }, | |
| 225 | .{ .name = "color", .kind = .string, .required = false }, | |
| 226 | }; | |
| 227 | const tab_bar_fields = [_]Field{ | |
| 228 | .{ .name = "selection", .kind = .object, .required = false, .object_fields = &tab_selection_fields }, | |
| 229 | }; | |
| 230 | ||
| 222 | 231 | /// `CraftConfig.Tab`. |
| 223 | 232 | const tab_fields = [_]Field{ |
| 224 | 233 | .{ .name = "id", .kind = .string, .required = true }, |
| @@ -260,6 +269,7 @@ fn isKind(v: std.json.Value, kind: Kind) bool { | ||
| 260 | 269 | .bool => v == .bool, |
| 261 | 270 | .number => isNumber(v), |
| 262 | 271 | .string_array => isStringArray(v), |
| 272 | .object => v == .object, | |
| 263 | 273 | }; |
| 264 | 274 | } |
| 265 | 275 | |
| @@ -277,6 +287,7 @@ fn checkObject(v: std.json.Value, fields: []const Field) bool { | ||
| 277 | 287 | continue; |
| 278 | 288 | } |
| 279 | 289 | if (!isKind(present, field.kind)) return false; |
| 290 | if (field.kind == .object and !checkObject(present, field.object_fields)) return false; | |
| 280 | 291 | } |
| 281 | 292 | return true; |
| 282 | 293 | } |
| @@ -840,6 +851,9 @@ test "nested types decode field by field, defaults or not" { | ||
| 840 | 851 | "\"tabs\":[{\"id\":\"health\",\"title\":\"Health\",\"selectedSymbol\":null,\"selectedIconTint\":null,\"selectedIconPulse\":null}]", |
| 841 | 852 | "\"crashReporting\":{\"key\":\"bughq_k\"}", |
| 842 | 853 | "\"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}", | |
| 843 | 857 | }) |good| { |
| 844 | 858 | const json = try completeConfig(testing.allocator, good); |
| 845 | 859 | defer testing.allocator.free(json); |
| @@ -862,6 +876,9 @@ test "nested types decode field by field, defaults or not" { | ||
| 862 | 876 | "\"tabs\":[{\"id\":\"a\",\"title\":\"A\",\"selectedIconPulse\":\"false\"}]", |
| 863 | 877 | "\"tabs\":[null]", |
| 864 | 878 | "\"tabs\":{}", |
| 879 | "\"tabBar\":[]", | |
| 880 | "\"tabBar\":{\"selection\":{\"cornerRadius\":\"8\"}}", | |
| 881 | "\"tabBar\":{\"selection\":{\"color\":42}}", | |
| 865 | 882 | }) |bad| { |
| 866 | 883 | const json = try completeConfig(testing.allocator, bad); |
| 867 | 884 | defer testing.allocator.free(json); |
| @@ -6509,10 +6509,66 @@ fn handleCreateWebView( | ||
| 6509 | 6509 | return null; |
| 6510 | 6510 | } |
| 6511 | 6511 | |
| 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). | |
| 6520 | fn 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 | ||
| 6512 | 6567 | /// Attach craft's `WKUIDelegate`. |
| 6513 | 6568 | /// |
| 6514 | 6569 | /// Two callbacks, neither of which has anything to do with the other: |
| 6515 | 6570 | /// `getUserMedia()` permission, and the page asking for a second window. |
| 6571 | /// The third callback supplies the AppKit panel behind HTML file inputs. | |
| 6516 | 6572 | /// |
| 6517 | 6573 | /// Installed in every non-benchmark window. It used to be installed only under |
| 6518 | 6574 | /// `--dev-tools`, which meant a packaged app was denied the camera outright |
| @@ -6563,6 +6619,18 @@ pub fn setupUIDelegate(webview: objc.id) !void { | ||
| 6563 | 6619 | } |
| 6564 | 6620 | } |
| 6565 | 6621 | |
| 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 | ||
| 6566 | 6634 | // `window.open()` / `target="_blank"`. Returns an object (the child |
| 6567 | 6635 | // webview, or nil): @ = id return, self, _cmd, webView, configuration, |
| 6568 | 6636 | // navigationAction, windowFeatures. |
| @@ -201,12 +201,16 @@ const dispatch_end = "func webView("; | ||
| 201 | 201 | /// and HKLiveWorkoutBuilder are owned by CraftHealth.swift, including their |
| 202 | 202 | /// delegate callbacks and iOS 26 availability; the actions remain on the |
| 203 | 203 | /// 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. | |
| 204 | 208 | /// 59 when the spec gained getHealthSamples: glucose, insulin and carbs one |
| 205 | 209 | /// sample at a time (bloodGlucose, insulinDelivery, dietaryCarbohydrates), an |
| 206 | 210 | /// HKSampleQuery in CraftHealth.swift like the other health reads. It went |
| 207 | 211 | /// into Swift first and falls through to the shim, which serves it. Not a |
| 208 | 212 | /// regression: no action left Zig. |
| 209 | const max_not_yet_migrated: usize = 59; | |
| 213 | const max_not_yet_migrated: usize = 61; | |
| 210 | 214 | |
| 211 | 215 | fn dispatcherRegion() []const u8 { |
| 212 | 216 | // 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 | ||
| 1123 | 1127 | "bool" |
| 1124 | 1128 | else if (std.mem.eql(u8, base, "Double")) |
| 1125 | 1129 | "number" |
| 1130 | else if (std.mem.eql(u8, base, "TabSelection")) | |
| 1131 | "object" | |
| 1126 | 1132 | else { |
| 1127 | 1133 | std.debug.print("CraftConfig.{s}.{s} is a {s}, which ios_config.zig has no Kind for.\n", .{ struct_name, member.name, base }); |
| 1128 | 1134 | return error.NestedFieldKindUnknown; |
| 1129 | 1135 | }; |
| 1130 | 1136 | |
| 1131 | 1137 | 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}", .{ | |
| 1133 | 1139 | member.name, kind, if (optional) "false" else "true", |
| 1134 | 1140 | }); |
| 1135 | 1141 | if (std.mem.indexOf(u8, zig_fields, expected) == null) { |
| @@ -1003,6 +1003,24 @@ test "webview delegate ownership ends with its webview" { | ||
| 1003 | 1003 | try testing.expect(callsFunction(handler_body, "msgSendVoid0(handler, \"release\")")); |
| 1004 | 1004 | } |
| 1005 | 1005 | |
| 1006 | test "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 | ||
| 1006 | 1024 | test "the scan can actually find the constructors it claims to check" { |
| 1007 | 1025 | // Guards the guard: every test above depends on `enclosingFnBody` locating |
| 1008 | 1026 | // real function bodies. If the file's formatting changes such that it |