ReviewOS

also looking at this

craft-native/craft

feat: finish native customization and platform seams

#401
Merged glennmichael123 wants to merge codex/issues-388-400-344 into main
21 files +707 -17

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.

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+19-0
Changes to packages/ios/src/index.test.ts
@@ -893,6 +893,25 @@ describe('Craft iOS builder', () => {
893893 expect(source).toContain('renderCommitted(document)')
894894 })
895895
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('case "getAppearance":')
910 expect(screen).toContain('CraftInterfaceStyleController.apply(to: self, config: config)')
911 expect(screen).toContain('craftNativeSetAppearance')
912 expect(screen).toContain("keep('app', {")
913 })
914
896915 it('emits native layout measurements only when a view changes frame', async () => {
897916 const output = createTempDir('craft-ios-native-layout-')
898917 await init({
packages/ios/templates/CraftApp.swiftmodified+98-0
Changes to packages/ios/templates/CraftApp.swift
@@ -102,6 +102,83 @@ 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 private static var selected: UIUserInterfaceStyle?
113
114 static func configuredStyle(for config: CraftConfig) -> UIUserInterfaceStyle {
115 switch config.appearance {
116 case "system": return .unspecified
117 case "light": return .light
118 case "dark": return .dark
119 default: return config.darkMode ? .dark : .light
120 }
121 }
122
123 static func configure(_ config: CraftConfig) {
124 selected = configuredStyle(for: config)
125 applyToWindows()
126 }
127
128 static func style(for config: CraftConfig) -> UIUserInterfaceStyle {
129 selected ?? configuredStyle(for: config)
130 }
131
132 /// Returns false rather than silently accepting a typo from JavaScript.
133 @discardableResult
134 static func set(_ value: String) -> Bool {
135 let style: UIUserInterfaceStyle
136 switch value {
137 case "system": style = .unspecified
138 case "light": style = .light
139 case "dark": style = .dark
140 default: return false
141 }
142 selected = style
143 applyToWindows()
144 NotificationCenter.default.post(name: .craftColorSchemeChanged, object: nil)
145 return true
146 }
147
148 static func currentName() -> String {
149 let requested = selected ?? UIApplication.shared.connectedScenes
150 .compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow })?.traitCollection.userInterfaceStyle }
151 .first ?? .unspecified
152 let style = requested == .unspecified
153 ? (UIApplication.shared.connectedScenes
154 .compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow })?.traitCollection.userInterfaceStyle }
155 .first { $0 != .unspecified } ?? UITraitCollection.current.userInterfaceStyle)
156 : requested
157 return style == .dark ? "dark" : "light"
158 }
159
160 static func apply(to controller: UIViewController, config: CraftConfig) {
161 controller.overrideUserInterfaceStyle = style(for: config)
162 }
163
164 private static func applyToWindows() {
165 let style = selected ?? .unspecified
166 for scene in UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }) {
167 for window in scene.windows {
168 window.overrideUserInterfaceStyle = style
169 apply(style, to: window.rootViewController)
170 }
171 }
172 }
173
174 private static func apply(_ style: UIUserInterfaceStyle, to controller: UIViewController?) {
175 guard let controller else { return }
176 controller.overrideUserInterfaceStyle = style
177 controller.children.forEach { apply(style, to: $0) }
178 if let presented = controller.presentedViewController { apply(style, to: presented) }
179 }
180}
181
105182/// Tells the shell when SwiftUI's colour scheme changes: the one appearance
106183/// setting with no notification of its own.
107184struct CraftColorSchemeObserver: View {
@@ -195,6 +272,7 @@ struct CraftRecoveryBudget {
195272final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
196273 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
197274 let config = CraftConfig.bundled()
275 CraftInterfaceStyleController.configure(config)
198276 // First, so a crash anywhere in the rest of launch is reported too.
199277 CraftCrashReporter.shared.start(config)
200278 UNUserNotificationCenter.current().delegate = self
@@ -2557,6 +2635,18 @@ struct CraftWebView: UIViewRepresentable {
25572635 resolveCallback(callbackId, result: CraftSymbols.image(named: body["name"] as? String ?? "", options: body) ?? NSNull())
25582636 case "statusBarSetStyle":
25592637 resolveCallback(callbackId, result: CraftStatusBar.setStyle(body["style"] as? String ?? "default"))
2638 case "setAppearance":
2639 guard let appearance = body["appearance"] as? String else {
2640 rejectCallback(callbackId, error: "setAppearance needs light, dark or system", code: "INVALID_ARGUMENT")
2641 return
2642 }
2643 guard CraftInterfaceStyleController.set(appearance) else {
2644 rejectCallback(callbackId, error: "setAppearance needs light, dark or system", code: "INVALID_ARGUMENT")
2645 return
2646 }
2647 resolveCallback(callbackId, result: true)
2648 case "getAppearance":
2649 resolveCallback(callbackId, result: CraftInterfaceStyleController.currentName())
25602650 case "chromeSetUnderPageColor":
25612651 // What shows past the page's edges when it rubber-bands.
25622652 if #available(iOS 15.0, *), let webView, let css = body["color"] as? String, let color = UIColor(css: css) {
@@ -5618,6 +5708,14 @@ struct CraftWebView: UIViewRepresentable {
56185708 getInfo: function() { return craft.getDeviceInfo(); },
56195709 getCapabilities: function() { return Promise.resolve(Object.assign({}, craft.capabilities)); }
56205710 };
5711 craft.app = Object.assign(craft.app || {}, {
5712 setAppearance: function(appearance) {
5713 return craft._invoke('setAppearance', {appearance: appearance});
5714 },
5715 getAppearance: function() {
5716 return craft._invoke('getAppearance', {});
5717 }
5718 });
56215719 // Feedback, as on Android and the web, where there is no
56225720 // motor to fire: an app that left enableHaptics off gets
56235721 // 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