@craft-native/ios 0.0.244 → 0.0.246

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import Foundation
2
2
  import CoreFoundation
3
+ import ImageIO
3
4
  import AVFoundation
4
5
  import AVKit
5
6
  import CryptoKit
@@ -3847,8 +3848,9 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
3847
3848
 
3848
3849
  @objc private func refreshPulled() {
3849
3850
  refreshHandler?()
3850
- // The handler's synchronous render has landed by now.
3851
- DispatchQueue.main.async { [weak self] in
3851
+ // Once the script has answered the pull (and its render has landed):
3852
+ // a script on its own thread answers a turn or two later.
3853
+ CraftNativeScriptThread.afterScript { [weak self] in
3852
3854
  guard let self, !self.refreshingProp, self.pullToRefresh.isRefreshing else { return }
3853
3855
  self.pullToRefresh.endRefreshing()
3854
3856
  }
@@ -4375,6 +4377,45 @@ private final class CraftNativeTextView: UITextView {
4375
4377
  }
4376
4378
  }
4377
4379
 
4380
+ /// Where native screens run their JavaScript.
4381
+ ///
4382
+ /// On the main thread by default. With `nativeScriptThread: "background"` in
4383
+ /// craft.config.json, every screen's script runs on one serial thread of its
4384
+ /// own (the shared virtual machine's only thread): evaluating its bundle,
4385
+ /// handling events and timers, and working out each render all leave the main
4386
+ /// thread, which only applies the results to UIKit. Messages between the two
4387
+ /// were already asynchronous JSON, so the order of everything is kept.
4388
+ enum CraftNativeScriptThread {
4389
+ static var isBackground = false
4390
+ static let queue = DispatchQueue(label: "craft.native.script", qos: .userInteractive)
4391
+
4392
+ /// `work` where the screens' JavaScript runs: now on the main thread, or
4393
+ /// queued in order on the script thread.
4394
+ static func run(_ work: @escaping () -> Void) {
4395
+ if isBackground { queue.async(execute: work) } else { work() }
4396
+ }
4397
+
4398
+ /// From the script thread, `work` on the main thread, not waited for.
4399
+ static func toMain(_ work: @escaping () -> Void) {
4400
+ if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) }
4401
+ }
4402
+
4403
+ /// From the script thread, `work` on the main thread, waited for: for the
4404
+ /// few host functions a script calls synchronously that read main-thread
4405
+ /// state (the Web Audio graph). The main thread never waits for the
4406
+ /// script thread, so this cannot deadlock.
4407
+ static func onMain<T>(_ work: () -> T) -> T {
4408
+ Thread.isMainThread ? work() : DispatchQueue.main.sync(execute: work)
4409
+ }
4410
+
4411
+ /// `work` on the main thread once a script has handled what was sent to
4412
+ /// it before now, and what it sent back has been applied.
4413
+ static func afterScript(_ work: @escaping () -> Void) {
4414
+ if isBackground { queue.async { DispatchQueue.main.async(execute: work) } }
4415
+ else { DispatchQueue.main.async(execute: work) }
4416
+ }
4417
+ }
4418
+
4378
4419
  /// The native screens' JavaScript. A screen is a JavaScriptCore context of
4379
4420
  /// its own and evaluates the whole file it is given, so each route has a file
4380
4421
  /// of its own when stx wrote one (`dist/native-screens/<route>.js`, stx's
@@ -4382,7 +4423,8 @@ private final class CraftNativeTextView: UITextView {
4382
4423
  /// screen's. The one-file `dist/native-screen.js` remains the fallback.
4383
4424
  enum CraftNativeBundle {
4384
4425
  /// Every screen's context lives in this one virtual machine. They all run
4385
- /// on the main thread, the machine's only one, and sharing it shares its
4426
+ /// on one thread (main, or the script thread: CraftNativeScriptThread),
4427
+ /// the machine's only one, and sharing it shares its
4386
4428
  /// code cache: a screen opened again (a second workout, a tab rebuilt)
4387
4429
  /// is not parsed again. Each screen keeps a global object of its own.
4388
4430
  static let virtualMachine = JSVirtualMachine()!
@@ -4409,10 +4451,39 @@ enum CraftNativeBundle {
4409
4451
  url(for: nil) != nil || Bundle.main.url(forResource: "native-screens", withExtension: nil, subdirectory: "dist") != nil
4410
4452
  }
4411
4453
 
4454
+ /// The runtime a route file runs on (stx's `__runtime.js`): evaluated
4455
+ /// first in each screen's context, as a source of its own, so the shared
4456
+ /// virtual machine parses it once for every screen. Nil for a one-file
4457
+ /// bundle, which carries its runtime itself, and for route files from an
4458
+ /// stx that wrote none.
4459
+ static func runtime(for route: URL) -> (source: String, url: URL)? {
4460
+ guard route.deletingLastPathComponent().lastPathComponent == "native-screens",
4461
+ let url = Bundle.main.url(forResource: "__runtime", withExtension: "js", subdirectory: "dist/native-screens") else { return nil }
4462
+ return read(url)
4463
+ }
4464
+
4465
+ /// A language's messages for an area, as JSON text: stx's
4466
+ /// `__messages/<language>/<area>.json` beside the route files. Nil for a
4467
+ /// language or area the build has none for, and for a name that is not
4468
+ /// one (no path outside the messages).
4469
+ static func messages(language: String, area: String) -> String? {
4470
+ guard language.range(of: #"^[A-Za-z0-9_-]+$"#, options: .regularExpression) != nil,
4471
+ area.range(of: #"^[A-Za-z0-9_-]+(/[A-Za-z0-9_-]+)*$"#, options: .regularExpression) != nil,
4472
+ let routes = Bundle.main.url(forResource: "native-screens", withExtension: nil, subdirectory: "dist") else { return nil }
4473
+ let url = routes.appendingPathComponent("__messages", isDirectory: true)
4474
+ .appendingPathComponent(language, isDirectory: true)
4475
+ .appendingPathComponent("\(area).json")
4476
+ return read(url)?.source
4477
+ }
4478
+
4412
4479
  /// The route's source, read from disk once and kept while memory allows:
4413
4480
  /// a push read and decoded the file on the main thread every time.
4414
4481
  static func source(for route: String?) -> (source: String, url: URL)? {
4415
4482
  guard let url = url(for: route) else { return nil }
4483
+ return read(url)
4484
+ }
4485
+
4486
+ private static func read(_ url: URL) -> (source: String, url: URL)? {
4416
4487
  let key = url.path as NSString
4417
4488
  if let cached = sources.object(forKey: key) { return (cached as String, url) }
4418
4489
  guard let text = try? String(contentsOf: url, encoding: .utf8) else { return nil }
@@ -4423,7 +4494,8 @@ enum CraftNativeBundle {
4423
4494
 
4424
4495
  final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate, UIPickerViewDataSource, UIPickerViewDelegate {
4425
4496
  private final class RenderedNode {
4426
- let identity: String
4497
+ /// A recycled list row's root takes the identity of the row it shows next.
4498
+ var identity: String
4427
4499
  let type: String
4428
4500
  let view: UIView
4429
4501
  var protocolId: String?
@@ -4509,6 +4581,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4509
4581
  }
4510
4582
  }
4511
4583
  private var flatListRows: [ObjectIdentifier: [String: RenderedNode]] = [:]
4584
+ /// Rows their cells left for reuse, by list and by the row's root view.
4585
+ private var flatListRecycled: [ObjectIdentifier: [ObjectIdentifier: RenderedNode]] = [:]
4512
4586
  private var flatListOwners: [String: String] = [:]
4513
4587
  /// A drawing's shapes are not views: a change to one redraws its `Svg`.
4514
4588
  private var svgOwners: [String: String] = [:]
@@ -4528,7 +4602,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4528
4602
  private let capabilityScope = UUID().uuidString
4529
4603
  private var pendingCapabilityRequests = Set<String>()
4530
4604
  private var pendingCapabilityDeadlines: [String: DispatchWorkItem] = [:]
4531
- private var pendingTimers: [Int: DispatchWorkItem] = [:]
4605
+ /// The script's timers. Touched only where the script runs.
4606
+ private final class Timers {
4607
+ var pending: [Int: DispatchWorkItem] = [:]
4608
+ }
4609
+ private let timers = Timers()
4532
4610
  private var lifecycleObservers: [NSObjectProtocol] = []
4533
4611
  private var deepLinkListener: UUID?
4534
4612
  private var keyboardSafeAreaInset: CGFloat = 0
@@ -4562,7 +4640,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4562
4640
  disposeRuntime()
4563
4641
  imageTasks.values.forEach { $0.cancel() }
4564
4642
  pendingCapabilityDeadlines.values.forEach { $0.cancel() }
4565
- pendingTimers.values.forEach { $0.cancel() }
4643
+ let timers = self.timers
4644
+ CraftNativeScriptThread.run { timers.pending.values.forEach { $0.cancel() } }
4566
4645
  pendingCapabilityRequests.forEach { CraftNativeActions.cancel(requestToken: $0) }
4567
4646
  lifecycleObservers.forEach(NotificationCenter.default.removeObserver)
4568
4647
  if let deepLinkListener = deepLinkListener { DeepLinkManager.shared.removeNativeListener(deepLinkListener) }
@@ -4591,8 +4670,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4591
4670
  rootTrailingToSafeArea!
4592
4671
  ])
4593
4672
  let pacing = CraftFramePacing.shared
4594
- pacing.measure("mount \(routeName ?? "screen") setup") { setupJavaScript() }
4595
- pacing.measure("mount \(routeName ?? "screen") bundle") { loadBundle() }
4673
+ let inputs = scriptInputs()
4674
+ let name = routeName ?? "screen"
4675
+ CraftNativeScriptThread.run { [self] in
4676
+ pacing.measure("mount \(name) setup") { setupJavaScript(inputs) }
4677
+ pacing.measure("mount \(name) bundle") { loadBundle() }
4678
+ }
4596
4679
  observeNativeEvents()
4597
4680
  if isAppScript {
4598
4681
  // Its bundle has run, so its listeners are in place: the app's
@@ -4727,14 +4810,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4727
4810
  func receiveRouteParams(_ params: [String: Any]) {
4728
4811
  routeParams = params
4729
4812
  guard isViewLoaded, !runtimeDisposed else { return }
4730
- jsContext.setObject(params as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString)
4813
+ let context = jsContext
4814
+ CraftNativeScriptThread.run { context.setObject(params as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString) }
4731
4815
  send(type: "ROUTE_PARAMS", payload: ["params": params])
4732
4816
  }
4733
4817
 
4734
4818
  private func disposeRuntime() {
4735
4819
  guard !runtimeDisposed else { return }
4736
4820
  runtimeDisposed = true
4737
- jsContext.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: [])
4821
+ let context = jsContext
4822
+ CraftNativeScriptThread.run { context.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: []) }
4738
4823
  audio.close()
4739
4824
  sounds.close()
4740
4825
  streams.close()
@@ -4796,7 +4881,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4796
4881
  return value.toString()
4797
4882
  }
4798
4883
 
4799
- private func setupJavaScript() {
4884
+ /// What a screen's script starts with that only the main thread may read:
4885
+ /// the app's state, the appearance, what the device can do.
4886
+ private struct ScriptInputs {
4887
+ let capabilityJSON: String
4888
+ let appState: String
4889
+ let colorScheme: String
4890
+ let appearanceJSON: String
4891
+ let sessionTokenKey: String
4892
+ }
4893
+
4894
+ private func scriptInputs() -> ScriptInputs {
4895
+ let capabilities = CraftNativeActions.capabilities(config: config)
4896
+ let capabilityData = try? JSONSerialization.data(withJSONObject: capabilities)
4897
+ return ScriptInputs(
4898
+ capabilityJSON: capabilityData.flatMap { String(data: $0, encoding: .utf8) } ?? "[]",
4899
+ appState: CraftNativeActions.currentAppState(),
4900
+ colorScheme: colorScheme,
4901
+ appearanceJSON: appearanceJSON(),
4902
+ sessionTokenKey: Self.jsonString(CraftSession.tokenKey(config))
4903
+ )
4904
+ }
4905
+
4906
+ private func setupJavaScript(_ inputs: ScriptInputs) {
4800
4907
  if let routeName = routeName {
4801
4908
  jsContext.setObject(routeName, forKeyedSubscript: "__stxNativeRoute" as NSString)
4802
4909
  }
@@ -4811,43 +4918,55 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4811
4918
  let stack = Self.safeJavaScriptString(exception?.objectForKeyedSubscript("stack")) ?? ""
4812
4919
  let screen = self?.screenName(in: context) ?? "screen"
4813
4920
  CraftNativeConsole.write("error", category: screen, message: stack.isEmpty || stack == "undefined" ? "Uncaught \(message)" : "Uncaught \(message)\n\(stack)")
4814
- // To the app's crash reporter too (bughq, when configured): an
4815
- // uncaught error in a shipped screen is otherwise only in a log
4816
- // nobody reads on someone else's phone.
4817
- CraftCrashReporter.shared.captureJavaScript(
4818
- name: Self.safeJavaScriptString(exception?.objectForKeyedSubscript("name")),
4819
- message: message,
4820
- stack: stack,
4821
- route: screen,
4822
- isAppScript: self?.isAppScript ?? false
4823
- )
4824
- self?.hybridFailed(message)
4921
+ let name = Self.safeJavaScriptString(exception?.objectForKeyedSubscript("name"))
4922
+ CraftNativeScriptThread.toMain { [weak self] in
4923
+ // To the app's crash reporter too (bughq, when configured):
4924
+ // an uncaught error in a shipped screen is otherwise only in
4925
+ // a log nobody reads on someone else's phone.
4926
+ CraftCrashReporter.shared.captureJavaScript(
4927
+ name: name,
4928
+ message: message,
4929
+ stack: stack,
4930
+ route: screen,
4931
+ isAppScript: self?.isAppScript ?? false
4932
+ )
4933
+ self?.hybridFailed(message)
4934
+ }
4825
4935
  }
4826
4936
  let postMessage: @convention(block) (String) -> Void = { [weak self] json in
4827
4937
  self?.receive(json)
4828
4938
  }
4829
4939
  jsContext.setObject(postMessage, forKeyedSubscript: "craftNativePostMessage" as NSString)
4940
+ // A language's messages for an area, as JSON text, the first time the
4941
+ // screen looks inside them (stx's `messages`): a screen parses only
4942
+ // the language it is shown in, and only the areas it uses. Called
4943
+ // from the screen's script, on its thread, and answered there.
4944
+ let readMessages: @convention(block) (String, String) -> Any = { language, area in
4945
+ CraftNativeBundle.messages(language: language, area: area) ?? NSNull()
4946
+ }
4947
+ jsContext.setObject(readMessages, forKeyedSubscript: "__stxNativeReadMessages" as NSString)
4830
4948
  // JavaScriptCore has no timers of its own. The callbacks stay in
4831
4949
  // JavaScript; native only owns the main-queue deadline, so route
4832
4950
  // teardown cancels every pending timer with the controller.
4833
- let scheduleTimer: @convention(block) (Int, Double) -> Void = { [weak self] id, delay in
4834
- guard let self = self else { return }
4835
- let work = DispatchWorkItem { [weak self] in
4836
- guard let self = self, self.pendingTimers.removeValue(forKey: id) != nil else { return }
4837
- self.jsContext.objectForKeyedSubscript("__craftNativeFireTimer")?.call(withArguments: [id])
4838
- }
4839
- self.pendingTimers[id] = work
4951
+ let timers = self.timers
4952
+ let context = jsContext
4953
+ let scheduleTimer: @convention(block) (Int, Double) -> Void = { id, delay in
4954
+ let work = DispatchWorkItem { [weak context] in
4955
+ guard timers.pending.removeValue(forKey: id) != nil else { return }
4956
+ context?.objectForKeyedSubscript("__craftNativeFireTimer")?.call(withArguments: [id])
4957
+ }
4958
+ timers.pending[id] = work
4840
4959
  let milliseconds = delay.isFinite ? Int(min(max(delay, 0), 2_147_483_647)) : 0
4841
- DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(milliseconds), execute: work)
4960
+ // Fired where the script runs.
4961
+ let queue = CraftNativeScriptThread.isBackground ? CraftNativeScriptThread.queue : .main
4962
+ queue.asyncAfter(deadline: .now() + .milliseconds(milliseconds), execute: work)
4842
4963
  }
4843
- let cancelTimer: @convention(block) (Int) -> Void = { [weak self] id in
4844
- self?.pendingTimers.removeValue(forKey: id)?.cancel()
4964
+ let cancelTimer: @convention(block) (Int) -> Void = { id in
4965
+ timers.pending.removeValue(forKey: id)?.cancel()
4845
4966
  }
4846
4967
  jsContext.setObject(scheduleTimer, forKeyedSubscript: "craftNativeScheduleTimer" as NSString)
4847
4968
  jsContext.setObject(cancelTimer, forKeyedSubscript: "craftNativeCancelTimer" as NSString)
4848
- let capabilities = CraftNativeActions.capabilities(config: config)
4849
- let capabilityData = try? JSONSerialization.data(withJSONObject: capabilities)
4850
- let capabilityJSON = capabilityData.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
4969
+ let capabilityJSON = inputs.capabilityJSON
4851
4970
  // The app's site and build, as JSON strings: the site an all-native
4852
4971
  // app's paths and links belong to (`devServerURL`), and the version a
4853
4972
  // request or a crash report names.
@@ -4855,6 +4974,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4855
4974
  let appURL = Self.jsonString(config.devServerURL ?? "")
4856
4975
  let appVersion = Self.jsonString(info?["CFBundleShortVersionString"] as? String ?? "")
4857
4976
  let appBuild = Self.jsonString(info?["CFBundleVersion"] as? String ?? "")
4977
+ let sessionTokenKey = inputs.sessionTokenKey
4858
4978
  jsContext.evaluateScript("""
4859
4979
  // JavaScriptCore has no DOM window, but browser-safe SDK bridges
4860
4980
  // address the host through `window.craft`. Keep native screens
@@ -4867,12 +4987,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4867
4987
  capabilityProtocolVersion: \(craftNativeCapabilityProtocolVersion),
4868
4988
  capabilityTimeoutMs: \(craftNativeCapabilityTimeoutMilliseconds),
4869
4989
  capabilities: \(capabilityJSON),
4870
- initialAppState: "\(CraftNativeActions.currentAppState())",
4871
- colorScheme: "\(colorScheme)",
4872
- appearance: \(appearanceJSON()),
4990
+ initialAppState: "\(inputs.appState)",
4991
+ colorScheme: "\(inputs.colorScheme)",
4992
+ appearance: \(inputs.appearanceJSON),
4873
4993
  appURL: \(appURL),
4874
4994
  appVersion: \(appVersion),
4875
4995
  appBuild: \(appBuild),
4996
+ sessionTokenKey: \(sessionTokenKey),
4876
4997
  postMessage: function(message) { craftNativePostMessage(message); },
4877
4998
  onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
4878
4999
  };
@@ -4966,6 +5087,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4966
5087
  /// else the one a route bundle selected for itself.
4967
5088
  private func screenName(in context: JSContext? = nil) -> String {
4968
5089
  if let routeName, !routeName.isEmpty { return routeName }
5090
+ // The script's own context is read only where the script runs.
5091
+ if context == nil, CraftNativeScriptThread.isBackground { return navigationItem.title ?? "screen" }
4969
5092
  if let selected = (context ?? jsContext).objectForKeyedSubscript("__stxNativeRoute"), selected.isString {
4970
5093
  return selected.toString()
4971
5094
  }
@@ -5015,13 +5138,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5015
5138
  }
5016
5139
  let snapshotSet: @convention(block) (String, JSValue, JSValue) -> Void = { name, value, done in
5017
5140
  CraftSnapshots.write(name, json: value.isNull || value.isUndefined ? nil : value.toString()) { ok in
5018
- done.call(withArguments: [ok])
5141
+ CraftNativeScriptThread.run { done.call(withArguments: [ok]) }
5019
5142
  }
5020
5143
  }
5021
5144
  let snapshotClear: @convention(block) (JSValue, JSValue) -> Void = { keep, done in
5022
5145
  let names = (keep.isUndefined || keep.isNull ? [] : (keep.toArray() ?? [])).compactMap { $0 as? String }
5023
5146
  CraftSnapshots.clear(keeping: Set(names)) { ok in
5024
- done.call(withArguments: [ok])
5147
+ CraftNativeScriptThread.run { done.call(withArguments: [ok]) }
5025
5148
  }
5026
5149
  }
5027
5150
  let config = self.config
@@ -5031,7 +5154,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5031
5154
  let setOptions: @convention(block) (String) -> Void = { [weak self] json in
5032
5155
  guard let data = json.data(using: .utf8),
5033
5156
  let options = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
5034
- self?.applyNavigationOptions(options)
5157
+ CraftNativeScriptThread.toMain { self?.applyNavigationOptions(options) }
5035
5158
  }
5036
5159
  jsContext.setObject(log, forKeyedSubscript: "craftNativeLog" as NSString)
5037
5160
  jsContext.setObject(storageGet, forKeyedSubscript: "craftNativeStorageGet" as NSString)
@@ -5274,6 +5397,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5274
5397
  return value === undefined ? null : value;
5275
5398
  }
5276
5399
  });
5400
+ // The signed-in account (CraftSession): the token under
5401
+ // `sessionTokenKey`, and each change of it, which the host
5402
+ // reports through `__craftSessionChanged` after the Keychain
5403
+ // holds it, beside the `SESSION_CHANGED` bridge message.
5404
+ var sessionListeners = [];
5405
+ var sessionKey = globalThis.__stxNativeBridge && __stxNativeBridge.sessionTokenKey || 'auth.token';
5406
+ function sessionToken() {
5407
+ var value = craftNativeSecureGet(String(sessionKey));
5408
+ return typeof value === 'string' && value !== '' ? value : null;
5409
+ }
5410
+ globalThis.__craftSessionChanged = function(token, previous) {
5411
+ sessionListeners.slice().forEach(function(callback) {
5412
+ try { callback(token, previous); } catch (error) { console.error('[session] ' + (error && error.stack || error)); }
5413
+ });
5414
+ };
5415
+ keep('session', {
5416
+ key: sessionKey,
5417
+ token: sessionToken,
5418
+ onChange: function(callback) {
5419
+ if (typeof callback !== 'function') throw new TypeError('craft.session.onChange needs a function');
5420
+ sessionListeners.push(callback);
5421
+ return function() {
5422
+ var index = sessionListeners.indexOf(callback);
5423
+ if (index >= 0) sessionListeners.splice(index, 1);
5424
+ };
5425
+ }
5426
+ });
5277
5427
  keep('navigation', {
5278
5428
  setOptions: function(options) { craftNativeSetOptions(JSON.stringify(options || {})); }
5279
5429
  });
@@ -5281,19 +5431,27 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5281
5431
  """)
5282
5432
  }
5283
5433
 
5434
+ /// Runs where the script runs (CraftNativeScriptThread).
5284
5435
  private func loadBundle() {
5285
5436
  guard let bundle = CraftNativeBundle.source(for: routeName) else {
5286
- showError("Missing dist/native-screen.js. Compile a .stx screen with `stx native compile` first.")
5437
+ CraftNativeScriptThread.toMain { [weak self] in
5438
+ self?.showError("Missing dist/native-screen.js. Compile a .stx screen with `stx native compile` first.")
5439
+ }
5287
5440
  return
5288
5441
  }
5442
+ if let runtime = CraftNativeBundle.runtime(for: bundle.url) {
5443
+ jsContext.evaluateScript(runtime.source, withSourceURL: runtime.url)
5444
+ }
5289
5445
  jsContext.evaluateScript(bundle.source, withSourceURL: bundle.url)
5290
5446
  // A bundle that names no route leaves `__stxNativeRoute` undefined;
5291
5447
  // its title is the app's, not the word "undefined". A title the
5292
5448
  // script set with `navigation.setOptions` wins over both.
5293
- if routeName == nil, !titleFromOptions,
5294
- let selected = jsContext.objectForKeyedSubscript("__stxNativeRoute"), selected.isString,
5295
- let name = selected.toString(), !name.isEmpty {
5296
- navigationItem.title = name
5449
+ guard routeName == nil,
5450
+ let selected = jsContext.objectForKeyedSubscript("__stxNativeRoute"), selected.isString,
5451
+ let name = selected.toString(), !name.isEmpty else { return }
5452
+ CraftNativeScriptThread.toMain { [weak self] in
5453
+ guard let self, !self.titleFromOptions else { return }
5454
+ self.navigationItem.title = name
5297
5455
  }
5298
5456
  }
5299
5457
 
@@ -5739,6 +5897,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5739
5897
  guard let name = notification.userInfo?["name"] as? String else { return }
5740
5898
  self?.send(type: "SNAPSHOT_CHANGED", payload: ["name": name])
5741
5899
  })
5900
+ if config.enableSecureStorage {
5901
+ // A sign-in, sign-out or another account, made on any screen or
5902
+ // the page (CraftSession): `SESSION_CHANGED { key, token,
5903
+ // previous }` for the runtime, then craft.session's listeners.
5904
+ lifecycleObservers.append(NotificationCenter.default.addObserver(
5905
+ forName: CraftSession.changed,
5906
+ object: nil,
5907
+ queue: .main
5908
+ ) { [weak self] notification in
5909
+ guard let self, let info = notification.userInfo, let key = info["key"] as? String else { return }
5910
+ let token = info["token"] as? String
5911
+ let previous = info["previous"] as? String
5912
+ self.send(type: "SESSION_CHANGED", payload: ["key": key, "token": token ?? NSNull(), "previous": previous ?? NSNull()])
5913
+ let context = self.jsContext
5914
+ CraftNativeScriptThread.run { context.objectForKeyedSubscript("__craftSessionChanged")?.call(withArguments: [token ?? NSNull(), previous ?? NSNull()]) }
5915
+ })
5916
+ }
5742
5917
  if config.enablePushNotifications {
5743
5918
  // A new device token (iOS hands one out after a reinstall or a
5744
5919
  // restore): every screen's `craft.push.onToken` hears it.
@@ -5905,10 +6080,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5905
6080
  hybridEvents?.nativeScreen(self, didFail: message)
5906
6081
  }
5907
6082
 
6083
+ /// A message from the screen's script, where the script runs: parsed
6084
+ /// there, then applied on the main thread in the order it was sent.
5908
6085
  private func receive(_ raw: String) {
5909
6086
  guard let data = raw.data(using: .utf8),
5910
6087
  let message = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
5911
- let type = message["type"] as? String else { return }
6088
+ message["type"] is String else { return }
6089
+ CraftNativeScriptThread.toMain { [weak self] in self?.handle(message) }
6090
+ }
6091
+
6092
+ private func handle(_ message: [String: Any]) {
6093
+ guard let type = message["type"] as? String else { return }
5912
6094
  let payload = message["payload"] as? [String: Any] ?? [:]
5913
6095
 
5914
6096
  switch type {
@@ -5955,11 +6137,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5955
6137
  let params = payload["params"] as? [String: Any] ?? [:]
5956
6138
  let next = CraftNativeScreenController(config: config, routeName: screen, routeParams: params)
5957
6139
  next.hybridEvents = hybridEvents
5958
- if type == "NAVIGATE" {
5959
- navigation.pushViewController(next, animated: true)
5960
- } else {
5961
- disposeRuntime()
5962
- navigation.setViewControllers(Array(navigation.viewControllers.dropLast()) + [next], animated: true)
6140
+ next.whenFirstRendered(within: 0.3) { [weak self, weak navigation] in
6141
+ guard let self, let navigation, navigation.topViewController === self else { return }
6142
+ if type == "NAVIGATE" {
6143
+ navigation.pushViewController(next, animated: true)
6144
+ } else {
6145
+ self.disposeRuntime()
6146
+ navigation.setViewControllers(Array(navigation.viewControllers.dropLast()) + [next], animated: true)
6147
+ }
5963
6148
  }
5964
6149
  case "NAVIGATION_SET_OPTIONS":
5965
6150
  applyNavigationOptions(payload)
@@ -6110,7 +6295,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6110
6295
  /// simulator-hosted XCTest of the host APIs.
6111
6296
  @discardableResult
6112
6297
  func evaluateScript(_ script: String) -> JSValue? {
6113
- jsContext.evaluateScript(script)
6298
+ // From the main thread (tests); waits for the script thread's turn.
6299
+ guard CraftNativeScriptThread.isBackground else { return jsContext.evaluateScript(script) }
6300
+ return CraftNativeScriptThread.queue.sync { jsContext.evaluateScript(script) }
6114
6301
  }
6115
6302
 
6116
6303
  // Internal so simulator-hosted XCTest can assert actual UIView identity.
@@ -6121,7 +6308,31 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6121
6308
  hybridRendered = true
6122
6309
  // The app script's drawing is never on screen: not a first frame.
6123
6310
  if !isAppScript { hybridEvents?.nativeScreenDidRender(self) }
6311
+ let waiting = renderWaiters
6312
+ renderWaiters.removeAll()
6313
+ waiting.forEach { $0() }
6314
+ }
6315
+ }
6316
+
6317
+ private var renderWaiters: [() -> Void] = []
6318
+
6319
+ /// `work` once the screen has its first content, or after `seconds`,
6320
+ /// whichever comes first. A screen whose script runs on its own thread
6321
+ /// (CraftNativeScriptThread) draws a moment after it is made: a push
6322
+ /// waits for that, so the screen never slides in empty. Now, with the
6323
+ /// script on the main thread, which drew it as it was made.
6324
+ func whenFirstRendered(within seconds: TimeInterval, _ work: @escaping () -> Void) {
6325
+ guard CraftNativeScriptThread.isBackground, !isAppScript else { work(); return }
6326
+ loadViewIfNeeded()
6327
+ guard !hybridRendered else { work(); return }
6328
+ var done = false
6329
+ let once = {
6330
+ guard !done else { return }
6331
+ done = true
6332
+ work()
6124
6333
  }
6334
+ renderWaiters.append(once)
6335
+ DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: once)
6125
6336
  }
6126
6337
 
6127
6338
  @discardableResult
@@ -6267,15 +6478,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6267
6478
  _ = focused.becomeFirstResponder()
6268
6479
  }
6269
6480
 
6270
- private func reconcile(_ node: [String: Any], identity: String, path: String, previous: RenderedNode?) -> RenderedNode {
6481
+ /// `adopting`: `previous` is a recycled list row's root, whose views may
6482
+ /// show this row instead (`identity` is the new row's).
6483
+ private func reconcile(_ node: [String: Any], identity: String, path: String, previous: RenderedNode?, adopting: Bool = false) -> RenderedNode {
6271
6484
  let type = canonicalType(node["type"] as? String ?? "View")
6272
6485
  let props = node["props"] as? [String: Any] ?? [:]
6273
6486
  let style = node["style"] as? [String: Any] ?? [:]
6274
6487
  let events = node["events"] as? [String: String] ?? [:]
6275
6488
  let children = node["children"] as? [Any] ?? []
6276
6489
  let current: RenderedNode
6277
- if let previous = previous, previous.identity == identity, canReuseView(node, previous: previous) {
6490
+ if let previous = previous, previous.identity == identity || adopting, canReuseView(node, previous: previous) {
6278
6491
  current = previous
6492
+ current.identity = identity
6279
6493
  } else {
6280
6494
  current = RenderedNode(identity: identity, type: type, view: makeView(type, props: props))
6281
6495
  }
@@ -7051,16 +7265,28 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7051
7265
  columns: Int(number(props["numColumns"]) ?? 1),
7052
7266
  inverted: props["inverted"] as? Bool == true,
7053
7267
  endReachedThreshold: threshold,
7054
- renderItem: { [weak self] node, identity, _ in
7268
+ renderItem: { [weak self] node, identity, reusable in
7055
7269
  guard let self = self else { return UIView() }
7056
- let previous = self.flatListRows[listKey]?[identity]
7270
+ var previous = self.flatListRows[listKey]?[identity]
7271
+ // A cell scrolled back into use brings the views of the row it
7272
+ // showed: the new row reuses them when it has the same shape,
7273
+ // rather than building every view of it again in the frame it
7274
+ // scrolls in (Apple's view-churn hitch).
7275
+ var adopting = false
7276
+ if previous == nil, let reusable,
7277
+ let recycled = self.flatListRecycled[listKey]?.removeValue(forKey: ObjectIdentifier(reusable)) {
7278
+ previous = recycled
7279
+ adopting = true
7280
+ }
7057
7281
  let next = self.reconcile(
7058
7282
  node,
7059
7283
  identity: "list:\(identity)",
7060
7284
  path: "\(path).\(identity)",
7061
- previous: previous
7285
+ previous: previous,
7286
+ adopting: adopting
7062
7287
  )
7063
- if let previous, previous.view !== next.view {
7288
+ // A recycled row's handlers went when it was recycled.
7289
+ if let previous, previous.view !== next.view, !adopting {
7064
7290
  self.forgetHandlers(previous)
7065
7291
  }
7066
7292
  self.flatListRows[listKey, default: [:]][identity] = next
@@ -7070,6 +7296,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7070
7296
  guard let self = self,
7071
7297
  let row = self.flatListRows[listKey]?.removeValue(forKey: identity) else { return }
7072
7298
  self.forgetHandlers(row, preservingInputDrafts: true)
7299
+ // Kept, without its handlers, for the next row its cell shows.
7300
+ var recycled = self.flatListRecycled[listKey, default: [:]]
7301
+ if recycled.count >= 64, let oldest = recycled.keys.first { recycled.removeValue(forKey: oldest) }
7302
+ recycled[ObjectIdentifier(row.view)] = row
7303
+ self.flatListRecycled[listKey] = recycled
7073
7304
  },
7074
7305
  endReached: nonEmptyHandler(events["onEndReached"]).map { [weak self] handler in
7075
7306
  { self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
@@ -7119,6 +7350,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7119
7350
  list.setRefreshHandler(nil, refreshing: false)
7120
7351
  list.discardAll()
7121
7352
  flatListRows.removeValue(forKey: listKey)
7353
+ flatListRecycled.removeValue(forKey: listKey)
7122
7354
  if let owner = node.protocolId { flatListOwners = flatListOwners.filter { $0.value != owner } }
7123
7355
  }
7124
7356
  if node.view is CraftNativeSvgView, let owner = node.protocolId { svgOwners = svgOwners.filter { $0.value != owner } }
@@ -7143,6 +7375,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7143
7375
  endEditingHandlers.removeValue(forKey: id)
7144
7376
  submitHandlers.removeValue(forKey: id)
7145
7377
  inputIdentities.removeValue(forKey: id)
7378
+ unechoedText.removeValue(forKey: id)
7146
7379
  if node.type == "TextInput" && !preservingInputDrafts { inputDrafts.removeValue(forKey: node.identity) }
7147
7380
  longPressHandlers.removeValue(forKey: id)
7148
7381
  textMaxLengths.removeValue(forKey: id)
@@ -7354,8 +7587,39 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7354
7587
  }
7355
7588
  }
7356
7589
 
7590
+ /// Text a field sent its script that the script has not echoed back yet,
7591
+ /// while the script runs on its own thread: a value that matches an older
7592
+ /// one is the script catching up, not a new value, and applying it would
7593
+ /// undo what was typed since.
7594
+ private var unechoedText: [ObjectIdentifier: [String]] = [:]
7595
+
7596
+ private func noteSentText(_ text: String, for view: UIView) {
7597
+ guard CraftNativeScriptThread.isBackground else { return }
7598
+ let id = ObjectIdentifier(view)
7599
+ var sent = unechoedText[id] ?? []
7600
+ sent.append(text)
7601
+ if sent.count > 32 { sent.removeFirst(sent.count - 32) }
7602
+ unechoedText[id] = sent
7603
+ }
7604
+
7605
+ /// Whether `value` is a stale echo of text the view has moved past: it
7606
+ /// matches text sent before the latest. Text the field never sent is the
7607
+ /// script's own value (a field cleared after sending) and applies.
7608
+ private func isStaleEcho(_ value: String, for view: UIView) -> Bool {
7609
+ let id = ObjectIdentifier(view)
7610
+ guard var sent = unechoedText[id], let index = sent.lastIndex(of: value) else {
7611
+ unechoedText[id] = nil
7612
+ return false
7613
+ }
7614
+ let later = sent.count - index - 1
7615
+ sent.removeFirst(index + 1)
7616
+ unechoedText[id] = sent.isEmpty ? nil : sent
7617
+ return later > 0
7618
+ }
7619
+
7357
7620
  private func updateField(_ field: UITextField, value: String?) {
7358
- guard let value = value, field.text != value, field.markedTextRange == nil else { return }
7621
+ guard let value = value, field.markedTextRange == nil else { return }
7622
+ if isStaleEcho(value, for: field) || field.text == value { return }
7359
7623
  let selection = field.selectedTextRange
7360
7624
  let start = selection.map { field.offset(from: field.beginningOfDocument, to: $0.start) }
7361
7625
  let end = selection.map { field.offset(from: field.beginningOfDocument, to: $0.end) }
@@ -7368,7 +7632,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7368
7632
  }
7369
7633
 
7370
7634
  private func updateTextView(_ textView: UITextView, value: String?) {
7371
- guard let value = value, textView.text != value, textView.markedTextRange == nil else { return }
7635
+ guard let value = value, textView.markedTextRange == nil else { return }
7636
+ if isStaleEcho(value, for: textView) || textView.text == value { return }
7372
7637
  let selection = textView.selectedTextRange
7373
7638
  let start = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.start) }
7374
7639
  let end = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.end) }
@@ -7852,6 +8117,64 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7852
8117
  return cache
7853
8118
  }()
7854
8119
 
8120
+ /// The pixels a picture is drawn at, when its style fixes its size (a
8121
+ /// 40-point avatar, a card's photo): nil otherwise, for a picture whose
8122
+ /// own size lays it out, which must keep its full size.
8123
+ static func displayPixels(of view: UIImageView) -> (size: CGSize, scale: CGFloat)? {
8124
+ let style = view.craftLayoutStyle
8125
+ let points: CGSize
8126
+ switch (style.width, style.height, style.aspectRatio) {
8127
+ case (.points(let width)?, .points(let height)?, _):
8128
+ points = CGSize(width: width, height: height)
8129
+ case (.points(let width)?, nil, let ratio?):
8130
+ points = CGSize(width: width, height: width / ratio)
8131
+ case (nil, .points(let height)?, let ratio?):
8132
+ points = CGSize(width: height * ratio, height: height)
8133
+ default:
8134
+ // Sized by its parent (a percentage, a stretch): its laid-out size.
8135
+ guard style.width != nil || style.height != nil || style.aspectRatio != nil,
8136
+ view.bounds.width >= 1, view.bounds.height >= 1 else { return nil }
8137
+ points = view.bounds.size
8138
+ }
8139
+ guard points.width >= 1, points.height >= 1 else { return nil }
8140
+ let scale = max(1, view.window?.screen.scale ?? view.traitCollection.displayScale)
8141
+ return (CGSize(width: ceil(points.width * scale), height: ceil(points.height * scale)), scale)
8142
+ }
8143
+
8144
+ /// Off the main thread: an image decoded at no more pixels than it is
8145
+ /// drawn at, by ImageIO's thumbnailer (Apple's downsampling recipe), so a
8146
+ /// 4000-pixel photo in a 40-point circle is never decoded whole; else
8147
+ /// decoded whole, ahead of its first draw.
8148
+ static func decodeImage(_ data: Data, fitting target: (size: CGSize, scale: CGFloat)?, fill: Bool) -> UIImage? {
8149
+ if let target,
8150
+ let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
8151
+ let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
8152
+ let rawWidth = (properties[kCGImagePropertyPixelWidth] as? NSNumber).map({ CGFloat(truncating: $0) }),
8153
+ let rawHeight = (properties[kCGImagePropertyPixelHeight] as? NSNumber).map({ CGFloat(truncating: $0) }),
8154
+ rawWidth > 0, rawHeight > 0 {
8155
+ // EXIF orientations 5–8 turn the picture a quarter.
8156
+ let turned = ((properties[kCGImagePropertyOrientation] as? NSNumber)?.intValue ?? 1) >= 5
8157
+ let width = turned ? rawHeight : rawWidth
8158
+ let height = turned ? rawWidth : rawHeight
8159
+ let pixels = target.size
8160
+ let factor = fill ? max(pixels.width / width, pixels.height / height) : min(pixels.width / width, pixels.height / height)
8161
+ if factor < 1 {
8162
+ let options = [
8163
+ kCGImageSourceCreateThumbnailFromImageAlways: true,
8164
+ kCGImageSourceShouldCacheImmediately: true,
8165
+ kCGImageSourceCreateThumbnailWithTransform: true,
8166
+ kCGImageSourceThumbnailMaxPixelSize: ceil(max(width, height) * factor),
8167
+ ] as CFDictionary
8168
+ if let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options) {
8169
+ // At the screen's scale, so its size in points is the
8170
+ // size it is drawn at.
8171
+ return UIImage(cgImage: image, scale: target.scale, orientation: .up)
8172
+ }
8173
+ }
8174
+ }
8175
+ return UIImage(data: data).map { $0.preparingForDisplay() ?? $0 }
8176
+ }
8177
+
7855
8178
  /// An image the screen downloads: https anywhere, and http only from this
7856
8179
  /// machine (localhost, 127.0.0.1, ::1, *.localhost), the development
7857
8180
  /// server the app's ATS exception already allows. Internal for XCTest.
@@ -7903,8 +8226,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7903
8226
  return
7904
8227
  }
7905
8228
  if let url = URL(string: uri), Self.loadsImage(from: url) {
8229
+ // Decoded at the size it is drawn, when the style fixes that size.
8230
+ let pixels = Self.displayPixels(of: view)
8231
+ let fill = view.contentMode == .scaleAspectFill
8232
+ let cacheKey = pixels.map { "\(requestKey)@\(Int($0.size.width))x\(Int($0.size.height))\(fill ? "f" : "")" } ?? requestKey
7906
8233
  // Shown before: on screen in this same frame, already decoded.
7907
- if let decoded = Self.decodedImages.object(forKey: requestKey as NSString) {
8234
+ if let decoded = Self.decodedImages.object(forKey: cacheKey as NSString) {
7908
8235
  view.image = imageForDisplay(decoded, view: view)
7909
8236
  imageDidChange(view)
7910
8237
  emitImageEvent(view, handler: imageLoadHandlers[id])
@@ -7917,10 +8244,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7917
8244
  // Decoded here, off the main thread. A UIImage made from data
7918
8245
  // is only decoded when it first draws, on the main thread, in
7919
8246
  // the frame it scrolls into view: Apple's "prepare" hitch.
7920
- let decoded = data.flatMap(UIImage.init(data:)).map { $0.preparingForDisplay() ?? $0 }
8247
+ let decoded = data.flatMap { Self.decodeImage($0, fitting: pixels, fill: fill) }
7921
8248
  if let decoded {
7922
- let pixels = decoded.size.width * decoded.scale * decoded.size.height * decoded.scale
7923
- Self.decodedImages.setObject(decoded, forKey: requestKey as NSString, cost: Int(pixels * 4))
8249
+ let bytes = decoded.size.width * decoded.scale * decoded.size.height * decoded.scale * 4
8250
+ Self.decodedImages.setObject(decoded, forKey: cacheKey as NSString, cost: Int(bytes))
7924
8251
  }
7925
8252
  guard let self, let view else { return }
7926
8253
  DispatchQueue.main.async {
@@ -8170,6 +8497,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8170
8497
  inputIdentities[id].map { inputDrafts[$0] = textView.text ?? "" }
8171
8498
  (textView as? CraftNativeTextView)?.refreshPlaceholder()
8172
8499
  guard let handler = handlers[id] else { return }
8500
+ noteSentText(textView.text ?? "", for: textView)
8173
8501
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? ""]])
8174
8502
  }
8175
8503
 
@@ -8197,6 +8525,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8197
8525
  inputIdentities[id].map { inputDrafts[$0] = sender.text ?? "" }
8198
8526
  let focused = sender.isFirstResponder ? sender : firstResponder(in: rootStack)
8199
8527
  guard let handler = handlers[id] else { return }
8528
+ noteSentText(sender.text ?? "", for: sender)
8200
8529
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
8201
8530
  restoreFocus(focused)
8202
8531
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self, weak focused] in
@@ -8478,7 +8807,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8478
8807
  if let correlationId = correlationId { message["correlationId"] = correlationId }
8479
8808
  guard let data = try? JSONSerialization.data(withJSONObject: message),
8480
8809
  let json = String(data: data, encoding: .utf8) else { return }
8481
- jsContext.objectForKeyedSubscript("__stxNativeCallback")?.call(withArguments: [json])
8810
+ let context = jsContext
8811
+ CraftNativeScriptThread.run { context.objectForKeyedSubscript("__stxNativeCallback")?.call(withArguments: [json]) }
8482
8812
  }
8483
8813
 
8484
8814
  private func number(_ value: Any?) -> CGFloat? {