also looking at this
fix(ios): let native screens choose interface appearance
#398
8 files
+218
-5
| @@ -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)"], |
| @@ -799,7 +799,7 @@ describe('Craft iOS builder', () => { | ||
| 799 | 799 | const launch = JSON.parse(readFileSync(join(output, 'Assets.xcassets', 'LaunchBackground.colorset', 'Contents.json'), 'utf8')) |
| 800 | 800 | expect(plist).toContain('<key>UIUserInterfaceStyle</key>\n <string>Automatic</string>') |
| 801 | 801 | expect(plist).toContain('<string>UIStatusBarStyleDefault</string>') |
| 802 | expect(swift).toContain('.preferredColorScheme(appState.config.colorScheme)') | |
| 802 | expect(swift).toContain('.preferredColorScheme(appState.appearanceScheme)') | |
| 803 | 803 | expect(launch.colors).toHaveLength(2) |
| 804 | 804 | expect(launch.colors[1].appearances).toEqual([{ appearance: 'luminosity', value: 'dark' }]) |
| 805 | 805 | expect(launch.colors[1].color.components.red).toBe('0.008') |
| @@ -830,6 +830,27 @@ describe('Craft iOS builder', () => { | ||
| 830 | 830 | expect(source).toContain('renderCommitted(document)') |
| 831 | 831 | }) |
| 832 | 832 | |
| 833 | it('lets native screens pin UIKit appearance at launch and at runtime', async () => { | |
| 834 | const output = createTempDir('craft-ios-native-appearance-') | |
| 835 | await init({ | |
| 836 | runtimeDir: null, | |
| 837 | name: 'NativeAppearance', | |
| 838 | bundleId: 'org.example.native-appearance', | |
| 839 | output, | |
| 840 | config: { renderer: 'native', appearance: 'system' }, | |
| 841 | }) | |
| 842 | const swift = readFileSync(join(output, 'Sources', 'NativeAppearanceApp.swift'), 'utf8') | |
| 843 | const screen = readFileSync(join(output, 'Sources', 'CraftNativeScreen.swift'), 'utf8') | |
| 844 | expect(swift).toContain('CraftInterfaceStyleController.configure(config)') | |
| 845 | expect(swift).toContain('CraftInterfaceStyleController.set(appearance)') | |
| 846 | expect(swift).toContain('.preferredColorScheme(appState.appearanceScheme)') | |
| 847 | expect(swift).toContain('CraftInterfaceStyleController.colorScheme(for: self.config)') | |
| 848 | expect(swift).toContain('case "getAppearance":') | |
| 849 | expect(screen).toContain('CraftInterfaceStyleController.apply(to: self, config: config)') | |
| 850 | expect(screen).toContain('craftNativeSetAppearance') | |
| 851 | expect(screen).toContain("keep('app', {") | |
| 852 | }) | |
| 853 | ||
| 833 | 854 | it('emits native layout measurements only when a view changes frame', async () => { |
| 834 | 855 | const output = createTempDir('craft-ios-native-layout-') |
| 835 | 856 | await init({ |
| @@ -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 |
| @@ -1557,6 +1648,8 @@ class DeepLinkManager { | ||
| 1557 | 1648 | // MARK: - App State |
| 1558 | 1649 | class AppState: ObservableObject { |
| 1559 | 1650 | @Published var config: CraftConfig |
| 1651 | @Published private(set) var appearanceScheme: ColorScheme? | |
| 1652 | private var appearanceObserver: NSObjectProtocol? | |
| 1560 | 1653 | |
| 1561 | 1654 | init() { |
| 1562 | 1655 | if let configURL = Bundle.main.url(forResource: "craft.config", withExtension: "json"), |
| @@ -1565,11 +1658,26 @@ class AppState: ObservableObject { | ||
| 1565 | 1658 | } else { |
| 1566 | 1659 | self.config = CraftConfig() |
| 1567 | 1660 | } |
| 1661 | self.appearanceScheme = CraftInterfaceStyleController.colorScheme(for: config) | |
| 1662 | appearanceObserver = NotificationCenter.default.addObserver( | |
| 1663 | forName: .craftColorSchemeChanged, | |
| 1664 | object: nil, | |
| 1665 | queue: .main, | |
| 1666 | ) { [weak self] _ in | |
| 1667 | guard let self else { return } | |
| 1668 | self.appearanceScheme = CraftInterfaceStyleController.colorScheme(for: self.config) | |
| 1669 | } | |
| 1568 | 1670 | // Before the first frame: the hybrid start screen, and the tab bar |
| 1569 | 1671 | // from the config, are decided here, if the app delegate has not |
| 1570 | 1672 | // already (it does at launch; the first call decides). |
| 1571 | 1673 | CraftHybrid.shared.configure(config) |
| 1572 | 1674 | } |
| 1675 | ||
| 1676 | deinit { | |
| 1677 | if let appearanceObserver { | |
| 1678 | NotificationCenter.default.removeObserver(appearanceObserver) | |
| 1679 | } | |
| 1680 | } | |
| 1573 | 1681 | } |
| 1574 | 1682 | |
| 1575 | 1683 | extension CraftConfig { |
| @@ -2535,6 +2643,18 @@ struct CraftWebView: UIViewRepresentable { | ||
| 2535 | 2643 | resolveCallback(callbackId, result: CraftSymbols.image(named: body["name"] as? String ?? "", options: body) ?? NSNull()) |
| 2536 | 2644 | case "statusBarSetStyle": |
| 2537 | 2645 | resolveCallback(callbackId, result: CraftStatusBar.setStyle(body["style"] as? String ?? "default")) |
| 2646 | case "setAppearance": | |
| 2647 | guard let appearance = body["appearance"] as? String else { | |
| 2648 | rejectCallback(callbackId, error: "setAppearance needs light, dark or system", code: "INVALID_ARGUMENT") | |
| 2649 | return | |
| 2650 | } | |
| 2651 | guard CraftInterfaceStyleController.set(appearance) else { | |
| 2652 | rejectCallback(callbackId, error: "setAppearance needs light, dark or system", code: "INVALID_ARGUMENT") | |
| 2653 | return | |
| 2654 | } | |
| 2655 | resolveCallback(callbackId, result: true) | |
| 2656 | case "getAppearance": | |
| 2657 | resolveCallback(callbackId, result: CraftInterfaceStyleController.currentName()) | |
| 2538 | 2658 | case "chromeSetUnderPageColor": |
| 2539 | 2659 | // What shows past the page's edges when it rubber-bands. |
| 2540 | 2660 | if #available(iOS 15.0, *), let webView, let css = body["color"] as? String, let color = UIColor(css: css) { |
| @@ -5596,6 +5716,14 @@ struct CraftWebView: UIViewRepresentable { | ||
| 5596 | 5716 | getInfo: function() { return craft.getDeviceInfo(); }, |
| 5597 | 5717 | getCapabilities: function() { return Promise.resolve(Object.assign({}, craft.capabilities)); } |
| 5598 | 5718 | }; |
| 5719 | craft.app = Object.assign(craft.app || {}, { | |
| 5720 | setAppearance: function(appearance) { | |
| 5721 | return craft._invoke('setAppearance', {appearance: appearance}); | |
| 5722 | }, | |
| 5723 | getAppearance: function() { | |
| 5724 | return craft._invoke('getAppearance', {}); | |
| 5725 | } | |
| 5726 | }); | |
| 5599 | 5727 | // Feedback, as on Android and the web, where there is no |
| 5600 | 5728 | // motor to fire: an app that left enableHaptics off gets |
| 5601 | 5729 | // nothing played and a settled promise, so `await |
| @@ -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 |
| @@ -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 | /** |
| @@ -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. |