ReviewOS

also looking at this

craft-native/craft

fix(ios): let native screens choose interface appearance

#398
Closed glennmichael123 wants to merge codex/issue-389-appearance into main
8 files +218 -5
packages/ios/fixtures/native-screen/NativeLayoutUnitTests.swiftmodified+18-0
Changes to packages/ios/fixtures/native-screen/NativeLayoutUnitTests.swift
@@ -17,6 +17,24 @@ final class NativeLayoutUnitTests: XCTestCase {
1717 return controller
1818 }
1919
20 func testNativeScreenUsesConfiguredUIKitAppearance() {
21 var config = CraftConfig()
22 config.appearance = "light"
23 let light = CraftNativeScreenController(config: config)
24 light.loadViewIfNeeded()
25 XCTAssertEqual(light.overrideUserInterfaceStyle, .light)
26
27 config.appearance = "dark"
28 let dark = CraftNativeScreenController(config: config)
29 dark.loadViewIfNeeded()
30 XCTAssertEqual(dark.overrideUserInterfaceStyle, .dark)
31
32 config.appearance = "system"
33 let system = CraftNativeScreenController(config: config)
34 system.loadViewIfNeeded()
35 XCTAssertEqual(system.overrideUserInterfaceStyle, .unspecified)
36 }
37
2038 func testReturningToAnUnchangedThemeRetainsTextAndScrollGeometry() throws {
2139 let rows: [[String: Any]] = (0..<100).map { index in
2240 ["id": "row-\(index)", "type": "Text", "props": ["testID": "row-\(index)"],
packages/ios/src/index.test.tsmodified+22-1
Changes to packages/ios/src/index.test.ts
@@ -799,7 +799,7 @@ describe('Craft iOS builder', () => {
799799 const launch = JSON.parse(readFileSync(join(output, 'Assets.xcassets', 'LaunchBackground.colorset', 'Contents.json'), 'utf8'))
800800 expect(plist).toContain('<key>UIUserInterfaceStyle</key>\n <string>Automatic</string>')
801801 expect(plist).toContain('<string>UIStatusBarStyleDefault</string>')
802 expect(swift).toContain('.preferredColorScheme(appState.config.colorScheme)')
802 expect(swift).toContain('.preferredColorScheme(appState.appearanceScheme)')
803803 expect(launch.colors).toHaveLength(2)
804804 expect(launch.colors[1].appearances).toEqual([{ appearance: 'luminosity', value: 'dark' }])
805805 expect(launch.colors[1].color.components.red).toBe('0.008')
@@ -830,6 +830,27 @@ describe('Craft iOS builder', () => {
830830 expect(source).toContain('renderCommitted(document)')
831831 })
832832
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
833854 it('emits native layout measurements only when a view changes frame', async () => {
834855 const output = createTempDir('craft-ios-native-layout-')
835856 await init({
packages/ios/templates/CraftApp.swiftmodified+129-1
Changes to packages/ios/templates/CraftApp.swift
@@ -102,6 +102,96 @@ enum CraftPageAppearance {
102102 }
103103}
104104
105/// The app-wide UIKit appearance override. SwiftUI's `preferredColorScheme`
106/// only controls the SwiftUI environment; native screen controllers need the
107/// UIKit override as well so their trait collection, controls and navigation
108/// chrome resolve the same way. A runtime choice is kept here so screens
109/// created after the choice inherit it instead of reverting to the bundle
110/// config.
111enum CraftInterfaceStyleController {
112 /// The app's bundled choice is the default for newly-created screens.
113 /// A runtime choice is separate: it is the only value that should
114 /// override a screen's own generated config.
115 private static var configured: UIUserInterfaceStyle = .unspecified
116 private static var runtimeSelection: UIUserInterfaceStyle?
117
118 static func configuredStyle(for config: CraftConfig) -> UIUserInterfaceStyle {
119 switch config.appearance {
120 case "system": return .unspecified
121 case "light": return .light
122 case "dark": return .dark
123 default: return config.darkMode ? .dark : .light
124 }
125 }
126
127 static func configure(_ config: CraftConfig) {
128 configured = configuredStyle(for: config)
129 runtimeSelection = nil
130 applyToWindows()
131 }
132
133 static func style(for config: CraftConfig) -> UIUserInterfaceStyle {
134 runtimeSelection ?? configuredStyle(for: config)
135 }
136
137 static func colorScheme(for config: CraftConfig) -> ColorScheme? {
138 switch style(for: config) {
139 case .light: return .light
140 case .dark: return .dark
141 default: return nil
142 }
143 }
144
145 /// Returns false rather than silently accepting a typo from JavaScript.
146 @discardableResult
147 static func set(_ value: String) -> Bool {
148 let style: UIUserInterfaceStyle
149 switch value {
150 case "system": style = .unspecified
151 case "light": style = .light
152 case "dark": style = .dark
153 default: return false
154 }
155 runtimeSelection = style
156 applyToWindows()
157 NotificationCenter.default.post(name: .craftColorSchemeChanged, object: nil)
158 return true
159 }
160
161 static func currentName() -> String {
162 let requested = runtimeSelection ?? UIApplication.shared.connectedScenes
163 .compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow })?.traitCollection.userInterfaceStyle }
164 .first ?? .unspecified
165 let style = requested == .unspecified
166 ? (UIApplication.shared.connectedScenes
167 .compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow })?.traitCollection.userInterfaceStyle }
168 .first { $0 != .unspecified } ?? UITraitCollection.current.userInterfaceStyle)
169 : requested
170 return style == .dark ? "dark" : "light"
171 }
172
173 static func apply(to controller: UIViewController, config: CraftConfig) {
174 controller.overrideUserInterfaceStyle = style(for: config)
175 }
176
177 private static func applyToWindows() {
178 let style = runtimeSelection ?? configured
179 for scene in UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }) {
180 for window in scene.windows {
181 window.overrideUserInterfaceStyle = style
182 apply(style, to: window.rootViewController)
183 }
184 }
185 }
186
187 private static func apply(_ style: UIUserInterfaceStyle, to controller: UIViewController?) {
188 guard let controller else { return }
189 controller.overrideUserInterfaceStyle = style
190 controller.children.forEach { apply(style, to: $0) }
191 if let presented = controller.presentedViewController { apply(style, to: presented) }
192 }
193}
194
105195/// Tells the shell when SwiftUI's colour scheme changes: the one appearance
106196/// setting with no notification of its own.
107197struct CraftColorSchemeObserver: View {
@@ -195,6 +285,7 @@ struct CraftRecoveryBudget {
195285final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
196286 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
197287 let config = CraftConfig.bundled()
288 CraftInterfaceStyleController.configure(config)
198289 // First, so a crash anywhere in the rest of launch is reported too.
199290 CraftCrashReporter.shared.start(config)
200291 UNUserNotificationCenter.current().delegate = self
@@ -606,7 +697,7 @@ struct CraftApp: App {
606697 .ignoresSafeArea(.keyboard, edges: .bottom)
607698 }
608699 }
609 .preferredColorScheme(appState.config.colorScheme)
700 .preferredColorScheme(appState.appearanceScheme)
610701 .environmentObject(appState)
611702 .onOpenURL { url in
612703 // Handle deep links and universal links
@@ -1557,6 +1648,8 @@ class DeepLinkManager {
15571648// MARK: - App State
15581649class AppState: ObservableObject {
15591650 @Published var config: CraftConfig
1651 @Published private(set) var appearanceScheme: ColorScheme?
1652 private var appearanceObserver: NSObjectProtocol?
15601653
15611654 init() {
15621655 if let configURL = Bundle.main.url(forResource: "craft.config", withExtension: "json"),
@@ -1565,11 +1658,26 @@ class AppState: ObservableObject {
15651658 } else {
15661659 self.config = CraftConfig()
15671660 }
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 }
15681670 // Before the first frame: the hybrid start screen, and the tab bar
15691671 // from the config, are decided here, if the app delegate has not
15701672 // already (it does at launch; the first call decides).
15711673 CraftHybrid.shared.configure(config)
15721674 }
1675
1676 deinit {
1677 if let appearanceObserver {
1678 NotificationCenter.default.removeObserver(appearanceObserver)
1679 }
1680 }
15731681}
15741682
15751683extension CraftConfig {
@@ -2535,6 +2643,18 @@ struct CraftWebView: UIViewRepresentable {
25352643 resolveCallback(callbackId, result: CraftSymbols.image(named: body["name"] as? String ?? "", options: body) ?? NSNull())
25362644 case "statusBarSetStyle":
25372645 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())
25382658 case "chromeSetUnderPageColor":
25392659 // What shows past the page's edges when it rubber-bands.
25402660 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 {
55965716 getInfo: function() { return craft.getDeviceInfo(); },
55975717 getCapabilities: function() { return Promise.resolve(Object.assign({}, craft.capabilities)); }
55985718 };
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 });
55995727 // Feedback, as on Android and the web, where there is no
56005728 // motor to fire: an app that left enableHaptics off gets
56015729 // nothing played and a settled promise, so `await
packages/ios/templates/CraftNativeScreen.swiftmodified+28-0
Changes to packages/ios/templates/CraftNativeScreen.swift
@@ -5545,6 +5545,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
55455545
55465546 override func viewDidLoad() {
55475547 super.viewDidLoad()
5548 CraftInterfaceStyleController.apply(to: self, config: config)
55485549 navigationItem.title = routeName ?? config.appName
55495550 view.backgroundColor = config.resolvedBackgroundColor
55505551 rootStack.axis = .vertical
@@ -6250,6 +6251,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
62506251 let secureGet: @convention(block) (String) -> String? = { key in
62516252 config.enableSecureStorage ? CraftNativeActions.webSecureValue(forKey: key) : nil
62526253 }
6254 let setAppearance: @convention(block) (String) -> Bool = { value in
6255 if Thread.isMainThread { return CraftInterfaceStyleController.set(value) }
6256 var accepted = false
6257 DispatchQueue.main.sync { accepted = CraftInterfaceStyleController.set(value) }
6258 return accepted
6259 }
6260 let getAppearance: @convention(block) () -> String = {
6261 if Thread.isMainThread { return CraftInterfaceStyleController.currentName() }
6262 var value = "light"
6263 DispatchQueue.main.sync { value = CraftInterfaceStyleController.currentName() }
6264 return value
6265 }
62536266 let setOptions: @convention(block) (String) -> Void = { [weak self] json in
62546267 guard let data = json.data(using: .utf8),
62556268 let options = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
@@ -6262,6 +6275,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
62626275 jsContext.setObject(snapshotSet, forKeyedSubscript: "craftNativeSnapshotSet" as NSString)
62636276 jsContext.setObject(snapshotClear, forKeyedSubscript: "craftNativeSnapshotClear" as NSString)
62646277 jsContext.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
6278 jsContext.setObject(setAppearance, forKeyedSubscript: "craftNativeSetAppearance" as NSString)
6279 jsContext.setObject(getAppearance, forKeyedSubscript: "craftNativeGetAppearance" as NSString)
62656280 jsContext.setObject(setOptions, forKeyedSubscript: "craftNativeSetOptions" as NSString)
62666281 // Older native bundles call the page-shaped `craft.dialog` API
62676282 // directly. Keep that API backed by the same capability protocol as
@@ -6504,6 +6519,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
65046519 return value === undefined ? null : value;
65056520 }
65066521 });
6522 keep('app', {
6523 setAppearance: function(value) {
6524 value = String(value);
6525 if (value !== 'light' && value !== 'dark' && value !== 'system') {
6526 return Promise.reject(new TypeError('craft.app.setAppearance needs light, dark or system'));
6527 }
6528 if (!craftNativeSetAppearance(value)) {
6529 return Promise.reject(new Error('craft.app.setAppearance was rejected'));
6530 }
6531 return Promise.resolve();
6532 },
6533 getAppearance: function() { return Promise.resolve(craftNativeGetAppearance()); }
6534 });
65076535 // The signed-in account (CraftSession): the token under
65086536 // `sessionTokenKey`, and each change of it, which the host
65096537 // reports through `__craftSessionChanged` after the Keychain
packages/typescript/src/__tests__/bridge-integration.test.tsmodified+8-0
Changes to packages/typescript/src/__tests__/bridge-integration.test.ts
@@ -35,6 +35,8 @@ describe('Bridge Type Definitions', () => {
3535 version: '1.0.0',
3636 platform: 'macos',
3737 }),
38 setAppearance: async (_appearance: 'light' | 'dark' | 'system') => {},
39 getAppearance: async () => 'light',
3840 },
3941 }
4042
@@ -87,6 +89,8 @@ describe('Bridge Type Definitions', () => {
8789 version: '1.0.0',
8890 platform: 'macos',
8991 }),
92 setAppearance: async (_appearance: 'light' | 'dark' | 'system') => {},
93 getAppearance: async () => 'light',
9094 }
9195
9296 expect(typeof mockApp.hideDockIcon).toBe('function')
@@ -292,6 +296,8 @@ describe('Bridge API Method Signatures', () => {
292296 version: '1.0.0',
293297 platform: 'macos',
294298 }),
299 setAppearance: async (_appearance: 'light' | 'dark' | 'system') => {},
300 getAppearance: async () => 'light',
295301 }
296302
297303 const info = await mockApp.getInfo()
@@ -334,6 +340,8 @@ describe('Window Global Type Augmentation', () => {
334340 version: '1.0.0',
335341 platform: 'macos',
336342 }),
343 setAppearance: async (_appearance: 'light' | 'dark' | 'system') => {},
344 getAppearance: async () => 'light',
337345 },
338346 } as CraftBridgeAPI,
339347 }
packages/typescript/src/api/app.tsmodified+2-2
Changes to packages/typescript/src/api/app.ts
@@ -629,14 +629,14 @@ class AppManager {
629629 // ==========================================================================
630630
631631 /**
632 * Set app appearance (macOS)
632 * Set the app appearance on native hosts.
633633 */
634634 async setAppearance(appearance: 'light' | 'dark' | 'system'): Promise<void> {
635635 await this._call('setAppearance', { appearance })
636636 }
637637
638638 /**
639 * Get current appearance
639 * Get the current effective Light/Dark appearance.
640640 */
641641 async getAppearance(): Promise<'light' | 'dark'> {
642642 return this._call('getAppearance')
packages/typescript/src/types.tsmodified+6-0
Changes to packages/typescript/src/types.ts
@@ -761,6 +761,12 @@ export interface CraftAppAPI {
761761 * Get application information
762762 */
763763 getInfo(): Promise<AppInfo>
764
765 /** Pin every native surface to Light, Dark, or the system setting. */
766 setAppearance(appearance: 'light' | 'dark' | 'system'): Promise<void>
767
768 /** Read the app's effective Light/Dark appearance. */
769 getAppearance(): Promise<'light' | 'dark'>
764770}
765771
766772/**
packages/zig/test/ios_conformance_test.zigmodified+5-1
Changes to packages/zig/test/ios_conformance_test.zig
@@ -201,12 +201,16 @@ const dispatch_end = "func webView(";
201201/// and HKLiveWorkoutBuilder are owned by CraftHealth.swift, including their
202202/// delegate callbacks and iOS 26 availability; the actions remain on the
203203/// Swift shim until the Zig bridge has an equivalent lifecycle channel.
204/// 61 when native screens gained setAppearance and getAppearance. They update
205/// the app-wide UIKit style and remain on the Swift side until Zig owns that
206/// lifecycle as well; each new action increases the outstanding count, and no
207/// existing Zig action was removed.
204208/// 59 when the spec gained getHealthSamples: glucose, insulin and carbs one
205209/// sample at a time (bloodGlucose, insulinDelivery, dietaryCarbohydrates), an
206210/// HKSampleQuery in CraftHealth.swift like the other health reads. It went
207211/// into Swift first and falls through to the shim, which serves it. Not a
208212/// regression: no action left Zig.
209const max_not_yet_migrated: usize = 59;
213const max_not_yet_migrated: usize = 61;
210214
211215fn dispatcherRegion() []const u8 {
212216 // CraftApp.swift also contains chrome, console and bridge-relay handlers.