also looking at this
feat: finish native customization and platform seams
#401
21 files
+707
-17
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -58,6 +58,27 @@ describe('Craft iOS builder', () => { | ||
| 58 | 58 | .toThrow('postGenerate hook failed with exit code 7: patch failed') |
| 59 | 59 | }) |
| 60 | 60 | |
| 61 | it('generates static tab-bar selection appearance and validates its shape', async () => { | |
| 62 | const output = createTempDir('craft-ios-tab-bar-appearance-') | |
| 63 | await init({ name: 'Tab Bar', output, runtimeDir: null, config: { | |
| 64 | tabBar: { selection: { cornerRadius: 8, color: '#123456' } }, | |
| 65 | } }) | |
| 66 | const config = JSON.parse(readFileSync(join(output, 'craft.config.json'), 'utf8')) | |
| 67 | const swift = readFileSync(join(output, 'Sources', 'TabBarApp.swift'), 'utf8') | |
| 68 | const hybrid = readFileSync(join(output, 'Sources', 'CraftHybrid.swift'), 'utf8') | |
| 69 | expect(config.tabBar).toEqual({ selection: { cornerRadius: 8, color: '#123456' } }) | |
| 70 | expect(swift).toContain('private(set) var tabSelectionColor: UIColor?') | |
| 71 | expect(swift).toContain('appearance: CraftConfig.TabBar? = nil') | |
| 72 | expect(swift).toContain('chrome.tabSelectionColor.map { Color($0) }') | |
| 73 | expect(hybrid).toContain('appearance: config.tabBar') | |
| 74 | await expect(init({ name: 'Invalid Tab Bar', output: join(output, 'invalid'), runtimeDir: null, config: { | |
| 75 | tabBar: { selection: { cornerRadius: -1 } }, | |
| 76 | } })).rejects.toThrow('cornerRadius must be a finite non-negative number') | |
| 77 | await expect(init({ name: 'Invalid Tab Color', output: join(output, 'invalid-color'), runtimeDir: null, config: { | |
| 78 | tabBar: { selection: { color: 'red' } }, | |
| 79 | } })).rejects.toThrow('color must be a #RRGGBB color') | |
| 80 | }) | |
| 81 | ||
| 61 | 82 | it('restores the shell with the actual Xcode Cloud build identity without changing other resources', () => { |
| 62 | 83 | const output = createTempDir('craft-cloud-identity-') |
| 63 | 84 | const file = join(output, 'craft.config.json') |
| @@ -74,6 +74,15 @@ export interface CraftPostGenerateHook { | ||
| 74 | 74 | args?: string[] |
| 75 | 75 | } |
| 76 | 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 | ||
| 77 | 86 | export interface CraftConfig { |
| 78 | 87 | appShortcuts?: CraftAppShortcut[] |
| 79 | 88 | /** Bundled font files keyed by their PostScript font name, used as native fontFamily. */ |
| @@ -92,6 +101,8 @@ export interface CraftConfig { | ||
| 92 | 101 | * and `dark` pin one. Unset keeps the older `darkMode` switch. |
| 93 | 102 | */ |
| 94 | 103 | appearance?: CraftAppearance |
| 104 | /** Static native tab-bar styling applied before the page's own tabBar.set call. */ | |
| 105 | tabBar?: CraftTabBarConfig | |
| 95 | 106 | /** Runs from the generated project root after `craft ios build` writes it. */ |
| 96 | 107 | postGenerate?: CraftPostGenerateHook |
| 97 | 108 | backgroundColor?: string |
| @@ -1546,6 +1557,11 @@ export async function init(options: InitOptions): Promise<void> { | ||
| 1546 | 1557 | if (config.renderer !== 'web' && config.renderer !== 'native') { |
| 1547 | 1558 | throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`) |
| 1548 | 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') | |
| 1549 | 1565 | if (config.enableBackgroundLocation) config.enableGeolocation = true |
| 1550 | 1566 | const problems = hybridConfigProblems(config) |
| 1551 | 1567 | if (problems.length) throw new Error(problems.join('\n')) |
| @@ -689,6 +689,10 @@ final class CraftChrome: ObservableObject { | ||
| 689 | 689 | /// The selected tab highlight's corner radius. Nil preserves the default |
| 690 | 690 | /// capsule, while a finite non-negative value opts into a rounded rect. |
| 691 | 691 | private(set) var tabSelectionCornerRadius: CGFloat? |
| 692 | /// The configured static shape, retained when the page updates only its | |
| 693 | /// tabs or selected item at runtime. | |
| 694 | private var configuredTabSelectionCornerRadius: CGFloat? | |
| 695 | private(set) var tabSelectionColor: UIColor? | |
| 692 | 696 | |
| 693 | 697 | weak var webView: WKWebView? |
| 694 | 698 | /// What the tab bar covers at the bottom of the screen, in points. |
| @@ -723,9 +727,13 @@ final class CraftChrome: ObservableObject { | ||
| 723 | 727 | /// The tab bar from `tabs` in craft.config.json, there before the page |
| 724 | 728 | /// loads; the page's own `tabBar` message replaces it later. `splash` |
| 725 | 729 | /// false when the first screen is native and draws itself. |
| 726 | func configure(tabs configured: [CraftConfig.Tab], selected: String?, splash: Bool) { | |
| 730 | func configure(tabs configured: [CraftConfig.Tab], selected: String?, splash: Bool, appearance: CraftConfig.TabBar? = nil) { | |
| 727 | 731 | splashVisible = splash |
| 728 | 732 | tabSwipeEnabled = true |
| 733 | configuredTabSelectionCornerRadius = appearance?.selection?.cornerRadius | |
| 734 | .flatMap { $0.isFinite && $0 >= 0 ? CGFloat($0) : nil } | |
| 735 | tabSelectionCornerRadius = configuredTabSelectionCornerRadius | |
| 736 | tabSelectionColor = appearance?.selection?.color.flatMap { UIColor(hex: $0) } | |
| 729 | 737 | guard !configured.isEmpty else { return } |
| 730 | 738 | 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 | 739 | self.selected = selected ?? configured.first?.id |
| @@ -856,6 +864,7 @@ final class CraftChrome: ObservableObject { | ||
| 856 | 864 | tint = light.map { light in dark.map { dark in UIColor { $0.userInterfaceStyle == .dark ? dark : light } } ?? light } |
| 857 | 865 | } |
| 858 | 866 | if let swipeEnabled = body["swipeEnabled"] as? Bool { tabSwipeEnabled = swipeEnabled } |
| 867 | if !body.keys.contains("selection") { tabSelectionCornerRadius = configuredTabSelectionCornerRadius } | |
| 859 | 868 | withAnimation(Self.motion(.easeOut(duration: 0.2))) { tabBarVisible = !tabs.isEmpty && !hiddenByScreen } |
| 860 | 869 | publishLayout() |
| 861 | 870 | case "selectTab": |
| @@ -1323,7 +1332,7 @@ private struct CraftTabSelectionIndicator: View { | ||
| 1323 | 1332 | let width = geometry.size.width / CGFloat(max(1, count)) |
| 1324 | 1333 | CraftTabSelectionShape(cornerRadius: cornerRadius) |
| 1325 | 1334 | .frame(width: width, height: geometry.size.height) |
| 1326 | .foregroundStyle(Color.primary.opacity(0.09)) | |
| 1335 | .foregroundStyle(chrome.tabSelectionColor.map { Color($0) } ?? Color.primary.opacity(0.09)) | |
| 1327 | 1336 | .offset(x: position * width) |
| 1328 | 1337 | } |
| 1329 | 1338 | } |
| @@ -1706,6 +1715,9 @@ struct CraftConfig: Codable { | ||
| 1706 | 1715 | var nativeScreens: [String: String]? = nil |
| 1707 | 1716 | /// The tab bar to show before the page describes its own. |
| 1708 | 1717 | var tabs: [Tab]? = nil |
| 1718 | /// Static styling for the selected tab indicator; the page may still | |
| 1719 | /// override its shape through `tabBar.set({ selection })`. | |
| 1720 | var tabBar: TabBar? = nil | |
| 1709 | 1721 | /// Hybrid: web-storage keys the page's own code writes, mirrored into the |
| 1710 | 1722 | /// Keychain under another name for native screens (`{"auth_token": "auth.token"}`). |
| 1711 | 1723 | var shareStorage: [String: String]? = nil |
| @@ -1733,6 +1745,15 @@ struct CraftConfig: Codable { | ||
| 1733 | 1745 | var selectedIconPulse: Bool? = nil |
| 1734 | 1746 | } |
| 1735 | 1747 | |
| 1748 | struct TabBar: Codable { | |
| 1749 | var selection: TabSelection? = nil | |
| 1750 | } | |
| 1751 | ||
| 1752 | struct TabSelection: Codable { | |
| 1753 | var cornerRadius: Double? = nil | |
| 1754 | var color: String? = nil | |
| 1755 | } | |
| 1756 | ||
| 1736 | 1757 | struct BackgroundRefresh: Codable { |
| 1737 | 1758 | var enabled: Bool = false |
| 1738 | 1759 | /// The BGTaskScheduler identifier; `<bundleId>.refresh` if unset. |
| @@ -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 |