craft-native 0.0.128 → 0.0.132

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.
@@ -1470,6 +1470,11 @@ struct CraftWebView: UIViewRepresentable {
1470
1470
  private var pendingCallbackId: String?
1471
1471
  private var pendingPushCallbackId: String?
1472
1472
  private var loadedBundledFallback = false
1473
+ /// Tries at the app's own page while it waits on the offline page out
1474
+ /// of sight, behind a hybrid app's native screen; reset by a page that
1475
+ /// loads.
1476
+ private var hiddenFallbackRetries = 0
1477
+ private var hiddenFallbackRetry: DispatchWorkItem?
1473
1478
  /// Whether the current document's bridge has announced itself.
1474
1479
  private var documentReady = false
1475
1480
  /// Reloads after the content process dies, budgeted per burst.
@@ -2594,7 +2599,10 @@ struct CraftWebView: UIViewRepresentable {
2594
2599
  CraftChrome.shared.pageFinished()
2595
2600
  CraftChrome.shared.publishLayout()
2596
2601
  guard isTrustedURL(webView.url) else { return }
2597
- if !loadedBundledFallback { lastPageURL = webView.url }
2602
+ if !loadedBundledFallback {
2603
+ lastPageURL = webView.url
2604
+ hiddenFallbackRetries = 0
2605
+ }
2598
2606
  // The bridge is a document-start user script, so it is normally
2599
2607
  // here long before this. A document that came without it is given
2600
2608
  // it now, the way every page used to be, so no page is ever worse
@@ -2703,6 +2711,7 @@ struct CraftWebView: UIViewRepresentable {
2703
2711
  private func showOfflinePage(in webView: WKWebView) {
2704
2712
  if config.devServerURL != nil { loadedBundledFallback = true }
2705
2713
  loadBundledPage(in: webView)
2714
+ retryHiddenFallbackLater()
2706
2715
  }
2707
2716
 
2708
2717
  /// Whether Info.plist declares WKAppBoundDomains.
@@ -2823,6 +2832,32 @@ struct CraftWebView: UIViewRepresentable {
2823
2832
  guard !loadedBundledFallback, config.devServerURL != nil else { return }
2824
2833
  loadedBundledFallback = true
2825
2834
  loadBundledPage(in: webView)
2835
+ retryHiddenFallbackLater()
2836
+ }
2837
+
2838
+ /// The offline page behind a hybrid app's native screen.
2839
+ ///
2840
+ /// Nobody sees its Retry there, and the page still has work to do
2841
+ /// behind the screen: sending a signed-out person to sign in, keeping
2842
+ /// the shared sign-in and snapshots current. A launch whose first load
2843
+ /// timed out (a slow network, a server still starting) left the page
2844
+ /// on the offline copy until the app next came to the foreground, and
2845
+ /// a signed-out person on a native screen that waits for the page. So
2846
+ /// the shell tries the page again itself, backing off from 2 to 30
2847
+ /// seconds; once the web view is on screen, its Retry is the person's.
2848
+ private func retryHiddenFallbackLater() {
2849
+ guard loadedBundledFallback, CraftHybrid.shared.isEnabled, !CraftHybrid.shared.webIsVisible else { return }
2850
+ hiddenFallbackRetry?.cancel()
2851
+ let delay = min(30.0, 2.0 * pow(2.0, Double(min(hiddenFallbackRetries, 4))))
2852
+ hiddenFallbackRetries += 1
2853
+ let work = DispatchWorkItem { [weak self] in
2854
+ guard let self, self.loadedBundledFallback, !CraftHybrid.shared.webIsVisible else { return }
2855
+ // In the background, coming back to the foreground retries.
2856
+ guard UIApplication.shared.applicationState == .active else { return }
2857
+ self.returnFromBundledFallback(because: "the page behind a native screen was on the offline page")
2858
+ }
2859
+ hiddenFallbackRetry = work
2860
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
2826
2861
  }
2827
2862
 
2828
2863
  /// Leave the bundled copy for the remote origin again (#252).
@@ -5809,14 +5844,23 @@ struct CraftWebView: UIViewRepresentable {
5809
5844
 
5810
5845
  // MARK: - App Badge
5811
5846
  private func setBadgeCount(_ count: Int, callbackId: String?) {
5812
- UNUserNotificationCenter.current().requestAuthorization(options: .badge) { granted, _ in
5813
- if granted {
5814
- DispatchQueue.main.async {
5815
- UIApplication.shared.applicationIconBadgeNumber = count
5816
- self.resolveCallback(callbackId, result: true)
5847
+ // Changing a badge must not ask for a badge-only authorization:
5848
+ // notification permission belongs to the explicit registration
5849
+ // flow, which requests alerts, badges and sounds together.
5850
+ DispatchQueue.main.async {
5851
+ if #available(iOS 16.0, *) {
5852
+ UNUserNotificationCenter.current().setBadgeCount(count) { [weak self] error in
5853
+ DispatchQueue.main.async {
5854
+ if let error {
5855
+ self?.rejectCallback(callbackId, error: error.localizedDescription)
5856
+ } else {
5857
+ self?.resolveCallback(callbackId, result: true)
5858
+ }
5859
+ }
5817
5860
  }
5818
5861
  } else {
5819
- self.rejectCallback(callbackId, error: "Badge permission denied")
5862
+ UIApplication.shared.applicationIconBadgeNumber = count
5863
+ self.resolveCallback(callbackId, result: true)
5820
5864
  }
5821
5865
  }
5822
5866
  }
@@ -573,13 +573,10 @@ final class CraftHybrid: NSObject {
573
573
  // `craft.snapshots` (CraftSnapshots, CraftNativeActions.swift).
574
574
  case .snapshotSet(let name, let json):
575
575
  CraftSnapshots.write(name, json: json) { ok in
576
- if ok { NotificationCenter.default.post(name: Self.snapshotChanged, object: nil, userInfo: ["name": name]) }
577
- else { NSLog("[craft hybrid] snapshot %@ was not written: not a snapshot name, or not JSON", name) }
576
+ if !ok { NSLog("[craft hybrid] snapshot %@ was not written: not a snapshot name, or not JSON", name) }
578
577
  }
579
578
  case .snapshotRemove(let name):
580
- CraftSnapshots.write(name, json: nil) { _ in
581
- NotificationCenter.default.post(name: Self.snapshotChanged, object: nil, userInfo: ["name": name])
582
- }
579
+ CraftSnapshots.write(name, json: nil)
583
580
  case .snapshotClear:
584
581
  let names = CraftSnapshots.directory
585
582
  .flatMap { try? FileManager.default.contentsOfDirectory(atPath: $0.path) }?
@@ -604,7 +601,7 @@ final class CraftHybrid: NSObject {
604
601
  }
605
602
 
606
603
  /// Posted when the page wrote or removed a snapshot (`userInfo["name"]`).
607
- static let snapshotChanged = Notification.Name("craftSnapshotChanged")
604
+ static let snapshotChanged = CraftSnapshots.changed
608
605
 
609
606
  // MARK: Tabs and links
610
607
 
@@ -680,6 +680,7 @@ extension CraftNativeActions {
680
680
  /// (`snapshots.set` in the mobile API) and a native screen reads them
681
681
  /// synchronously before its first render, or writes its own.
682
682
  enum CraftSnapshots {
683
+ static let changed = Notification.Name("craftSnapshotChanged")
683
684
  private static let queue = DispatchQueue(label: "dev.craft.snapshots", qos: .utility)
684
685
  private static let lock = NSLock()
685
686
  /// Writes not on disk yet, so a read right after a write sees it.
@@ -742,7 +743,10 @@ enum CraftSnapshots {
742
743
  lock.lock()
743
744
  if let current = pending[name], current == json { pending.removeValue(forKey: name) }
744
745
  lock.unlock()
745
- DispatchQueue.main.async { completion?(ok) }
746
+ DispatchQueue.main.async {
747
+ if ok { NotificationCenter.default.post(name: changed, object: nil, userInfo: ["name": name]) }
748
+ completion?(ok)
749
+ }
746
750
  }
747
751
  }
748
752
  }
@@ -195,9 +195,9 @@ final class CraftNativeMutationDocument {
195
195
  if let unknown = patch.keys.sorted().first(where: { !allowed.contains($0) }) {
196
196
  throw CraftNativeMutationFailure("INVALID_PATCH", "unsupported patch field \(unknown)")
197
197
  }
198
- if patch.keys.contains("props") { node.props = dictionary(patch["props"]) }
199
- if patch.keys.contains("style") { node.style = dictionary(patch["style"]) }
200
- if patch.keys.contains("events") { node.events = dictionary(patch["events"]) }
198
+ if patch.keys.contains("props") { mergePatch(patch["props"], into: &node.props) }
199
+ if patch.keys.contains("style") { mergePatch(patch["style"], into: &node.style) }
200
+ if patch.keys.contains("events") { mergePatch(patch["events"], into: &node.events) }
201
201
  if patch.keys.contains("children") || patch.keys.contains("text") {
202
202
  node.text = try textChildren(patch["children"] ?? patch["text"])
203
203
  }
@@ -352,6 +352,13 @@ final class CraftNativeMutationDocument {
352
352
  value as? [String: Any] ?? [:]
353
353
  }
354
354
 
355
+ private func mergePatch(_ value: Any?, into target: inout [String: Any]) {
356
+ for (key, next) in dictionary(value) {
357
+ if next is NSNull { target.removeValue(forKey: key) }
358
+ else { target[key] = next }
359
+ }
360
+ }
361
+
355
362
  private func integer(_ value: Any?) -> Int? {
356
363
  guard let number = value as? NSNumber else { return nil }
357
364
  guard CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil }
@@ -241,6 +241,7 @@ private final class CraftNativeFlowView: UIStackView {
241
241
  var pressBaseOpacity: CGFloat = 1
242
242
  var wrap = false { didSet { if wrap != oldValue { invalidateMeasurements() } } }
243
243
  var alignItems = "stretch" { didSet { alignment = alignmentValue; if alignItems != oldValue { invalidateMeasurements() } } }
244
+ var alignContent = "flex-start" { didSet { if alignContent != oldValue { invalidateMeasurements() } } }
244
245
  var justifyContent = "flex-start" { didSet { distribution = distributionValue; if justifyContent != oldValue { invalidateMeasurements() } } }
245
246
  var gap: CGFloat = 0 { didSet { spacing = gap; if gap != oldValue { invalidateMeasurements() } } }
246
247
  var rowGap: CGFloat? { didSet { if rowGap != oldValue { invalidateMeasurements() } } }
@@ -641,11 +642,29 @@ private final class CraftNativeFlowView: UIStackView {
641
642
  }
642
643
 
643
644
  let contentMain = definiteMain ?? (lineMains.max() ?? 0)
644
- let contentCross = definiteCross
645
+ var contentCross = definiteCross
645
646
  ?? (lineCrosses.reduce(0, +) + CGFloat(max(0, lineCrosses.count - 1)) * crossGap)
647
+ var crossLeading: CGFloat = 0
648
+ var crossBetween = crossGap
649
+ if wrap, let definiteCross, lineCrosses.count > 1 {
650
+ let free = max(0, definiteCross - (lineCrosses.reduce(0, +) + CGFloat(lineCrosses.count - 1) * crossGap))
651
+ switch alignContent {
652
+ case "center": crossLeading = free / 2
653
+ case "flex-end": crossLeading = free
654
+ case "space-between": crossBetween = free / CGFloat(lineCrosses.count - 1) + crossGap
655
+ case "space-around":
656
+ crossBetween = free / CGFloat(lineCrosses.count) + crossGap
657
+ crossLeading = free / CGFloat(lineCrosses.count * 2)
658
+ case "stretch":
659
+ let extra = free / CGFloat(lineCrosses.count)
660
+ lineCrosses = lineCrosses.map { $0 + extra }
661
+ default: break
662
+ }
663
+ contentCross = definiteCross
664
+ }
646
665
 
647
666
  if place {
648
- var crossOffset: CGFloat = 0
667
+ var crossOffset = crossLeading
649
668
  for (lineIndex, line) in lines.enumerated() {
650
669
  let free = max(0, contentMain - lineMains[lineIndex])
651
670
  let count = line.count
@@ -676,7 +695,7 @@ private final class CraftNativeFlowView: UIStackView {
676
695
  frames.append((item.view, frame))
677
696
  mainOffset += item.mainMargins + item.main + mainGap + between
678
697
  }
679
- crossOffset += lineCross + crossGap
698
+ crossOffset += lineCross + crossBetween
680
699
  }
681
700
  }
682
701
  return horizontal ? CGSize(width: contentMain, height: contentCross) : CGSize(width: contentCross, height: contentMain)
@@ -756,6 +775,7 @@ private final class CraftNativeModalView: UIView {
756
775
  let contentStack = CraftNativeFlowView()
757
776
  private let blocker = UIView()
758
777
  private var renderedVisible = false
778
+ private var animationGeneration = 0
759
779
  var onRequestClose: (() -> Void)?
760
780
 
761
781
  var transparent = false {
@@ -776,22 +796,63 @@ private final class CraftNativeModalView: UIView {
776
796
  required init?(coder: NSCoder) { nil }
777
797
 
778
798
  func setVisible(_ visible: Bool, opacity: CGFloat, animationType: String?) {
779
- guard renderedVisible != visible || isHidden != !visible else { return }
799
+ if renderedVisible == visible && isHidden == !visible {
800
+ if visible, alpha != opacity { alpha = opacity }
801
+ return
802
+ }
803
+ let wasVisible = renderedVisible && !isHidden
780
804
  renderedVisible = visible
805
+ animationGeneration += 1
806
+ let generation = animationGeneration
807
+ layer.removeAllAnimations()
781
808
  guard visible else {
782
- isHidden = true
783
- alpha = 0
809
+ if animationType == "slide" && wasVisible {
810
+ let distance = max(bounds.height, superview?.bounds.height ?? UIScreen.main.bounds.height)
811
+ UIView.animate(withDuration: 0.24, animations: {
812
+ self.transform = CGAffineTransform(translationX: 0, y: distance)
813
+ self.alpha = 0
814
+ }, completion: { _ in
815
+ guard self.animationGeneration == generation else { return }
816
+ self.isHidden = true
817
+ self.transform = .identity
818
+ })
819
+ } else {
820
+ isHidden = true
821
+ transform = .identity
822
+ alpha = 0
823
+ }
784
824
  return
785
825
  }
786
826
  isHidden = false
787
827
  if animationType == "none" {
828
+ transform = .identity
829
+ alpha = opacity
830
+ } else if animationType == "slide" {
831
+ let distance = max(bounds.height, superview?.bounds.height ?? UIScreen.main.bounds.height)
832
+ transform = CGAffineTransform(translationX: 0, y: distance)
788
833
  alpha = opacity
834
+ DispatchQueue.main.async {
835
+ guard self.animationGeneration == generation, !self.isHidden else { return }
836
+ let distance = max(self.bounds.height, self.superview?.bounds.height ?? UIScreen.main.bounds.height)
837
+ self.transform = CGAffineTransform(translationX: 0, y: distance)
838
+ UIView.animate(withDuration: 0.24) { self.transform = .identity }
839
+ }
789
840
  } else {
841
+ transform = .identity
790
842
  alpha = 0
791
- UIView.animate(withDuration: animationType == "slide" ? 0.24 : 0.18) { self.alpha = opacity }
843
+ UIView.animate(withDuration: 0.18) { self.alpha = opacity }
792
844
  }
793
845
  }
794
846
 
847
+ func resetForTeardown() {
848
+ animationGeneration += 1
849
+ layer.removeAllAnimations()
850
+ isHidden = true
851
+ transform = .identity
852
+ alpha = 0
853
+ onRequestClose = nil
854
+ }
855
+
795
856
  @objc private func blockerTapped() { onRequestClose?() }
796
857
 
797
858
  override func layoutSubviews() {
@@ -1235,6 +1296,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1235
1296
  /// could not be drawn, and of `craft.navigation.open(path)`.
1236
1297
  weak var hybridEvents: CraftHybridScreenEvents?
1237
1298
  private var hybridRendered = false
1299
+ private var runtimeDisposed = false
1238
1300
 
1239
1301
  init(config: CraftConfig, routeName: String? = nil, routeParams: [String: Any] = [:]) {
1240
1302
  self.config = config
@@ -1248,6 +1310,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1248
1310
  }
1249
1311
 
1250
1312
  deinit {
1313
+ disposeRuntime()
1251
1314
  imageTasks.values.forEach { $0.cancel() }
1252
1315
  pendingCapabilityDeadlines.values.forEach { $0.cancel() }
1253
1316
  pendingTimers.values.forEach { $0.cancel() }
@@ -1297,6 +1360,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1297
1360
  refreshTraitDefaults()
1298
1361
  }
1299
1362
 
1363
+ override func viewDidDisappear(_ animated: Bool) {
1364
+ super.viewDidDisappear(animated)
1365
+ if isMovingFromParent || navigationController?.viewControllers.contains(where: { $0 === self }) == false {
1366
+ disposeRuntime()
1367
+ }
1368
+ }
1369
+
1370
+ private func disposeRuntime() {
1371
+ guard !runtimeDisposed else { return }
1372
+ runtimeDisposed = true
1373
+ jsContext.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: [])
1374
+ }
1375
+
1300
1376
  private func refreshTraitDefaults() {
1301
1377
  view.backgroundColor = config.resolvedBackgroundColor
1302
1378
  if let document = mutationDocument.node("root") {
@@ -1318,14 +1394,24 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1318
1394
  emitLayoutEvents()
1319
1395
  }
1320
1396
 
1397
+ private static func safeJavaScriptString(_ value: JSValue?) -> String? {
1398
+ guard let value, !value.isUndefined, !value.isNull, value.isString else { return nil }
1399
+ return value.toString()
1400
+ }
1401
+
1321
1402
  private func setupJavaScript() {
1322
1403
  if let routeName = routeName {
1323
1404
  jsContext.setObject(routeName, forKeyedSubscript: "__stxNativeRoute" as NSString)
1324
1405
  }
1325
1406
  jsContext.setObject(routeParams as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString)
1326
1407
  jsContext.exceptionHandler = { [weak self] context, exception in
1327
- let message = exception?.toString() ?? "unknown"
1328
- let stack = exception?.objectForKeyedSubscript("stack")?.toString() ?? ""
1408
+ // Never stringify an arbitrary JS object here. JavaScriptCore can
1409
+ // re-enter this handler while coercing an exception whose
1410
+ // `toString` is broken, turning a page error into a native crash.
1411
+ let message = Self.safeJavaScriptString(exception?.objectForKeyedSubscript("message"))
1412
+ ?? Self.safeJavaScriptString(exception)
1413
+ ?? "unknown"
1414
+ let stack = Self.safeJavaScriptString(exception?.objectForKeyedSubscript("stack")) ?? ""
1329
1415
  CraftNativeConsole.write("error", category: self?.screenName(in: context) ?? "screen", message: stack.isEmpty || stack == "undefined" ? "Uncaught \(message)" : "Uncaught \(message)\n\(stack)")
1330
1416
  self?.hybridFailed(message)
1331
1417
  }
@@ -1647,6 +1733,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1647
1733
  let action = UIAction { [weak self] _ in
1648
1734
  self?.send(type: "EVENT", payload: ["handlerName": "navButton", "nativeEvent": ["id": id]])
1649
1735
  }
1736
+ if spec["variant"] as? String == "accent" || spec["variant"] as? String == "account" {
1737
+ return accountNavigationButton(spec, action: action)
1738
+ }
1650
1739
  let item: UIBarButtonItem
1651
1740
  if let symbol = spec["symbol"] as? String, let image = UIImage(systemName: symbol) {
1652
1741
  item = UIBarButtonItem(image: image, primaryAction: action)
@@ -1682,6 +1771,63 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1682
1771
  return item
1683
1772
  }
1684
1773
 
1774
+ /// Shared account/chat controls: the same 44-point target, independent
1775
+ /// circular surface and unread indicator on every native screen.
1776
+ private func accountNavigationButton(_ spec: [String: Any], action: UIAction) -> UIBarButtonItem {
1777
+ let button = UIButton(type: .custom)
1778
+ button.addAction(action, for: .touchUpInside)
1779
+ button.translatesAutoresizingMaskIntoConstraints = false
1780
+ NSLayoutConstraint.activate([button.widthAnchor.constraint(equalToConstant: 44), button.heightAnchor.constraint(equalToConstant: 44)])
1781
+ let accent = spec["variant"] as? String == "accent"
1782
+ button.backgroundColor = UIColor { traits in
1783
+ if traits.userInterfaceStyle == .dark { return UIColor(red: 0.15, green: 0.39, blue: 0.92, alpha: accent ? 0.24 : 0.12) }
1784
+ return UIColor(red: 0.15, green: 0.39, blue: 0.92, alpha: 0.08)
1785
+ }
1786
+ button.layer.cornerRadius = 22
1787
+ button.tintColor = UIColor { $0.userInterfaceStyle == .dark ? UIColor(red: 0.58, green: 0.77, blue: 0.99, alpha: 1) : UIColor(red: 0.11, green: 0.30, blue: 0.85, alpha: 1) }
1788
+ button.setTitleColor(button.tintColor, for: .normal)
1789
+ button.titleLabel?.font = .systemFont(ofSize: 13, weight: .semibold)
1790
+ if let title = spec["title"] as? String, !title.isEmpty { button.setTitle(title, for: .normal) }
1791
+ else if let symbol = spec["symbol"] as? String { button.setImage(UIImage(systemName: symbol, withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)), for: .normal) }
1792
+ button.accessibilityIdentifier = spec["id"] as? String
1793
+ button.accessibilityLabel = spec["accessibilityLabel"] as? String
1794
+ if let value = spec["badge"] as? NSNumber, value.intValue > 0 {
1795
+ let dot = spec["badgeStyle"] as? String == "dot"
1796
+ let badge = UILabel()
1797
+ badge.text = dot ? nil : value.intValue > 99 ? "99+" : String(value.intValue)
1798
+ badge.font = .systemFont(ofSize: 10, weight: .bold)
1799
+ badge.textColor = .white
1800
+ badge.textAlignment = .center
1801
+ badge.backgroundColor = dot ? .systemRed : .systemBlue
1802
+ badge.clipsToBounds = true
1803
+ let size: CGFloat = dot ? 9 : 18
1804
+ badge.layer.cornerRadius = size / 2
1805
+ badge.frame = CGRect(x: 44 - size, y: 0, width: size, height: size)
1806
+ badge.isAccessibilityElement = false
1807
+ button.addSubview(badge)
1808
+ button.accessibilityValue = "\(value.intValue) unread"
1809
+ }
1810
+ if let source = spec["image"] as? String, let url = URL(string: source), url.scheme == "https" {
1811
+ loadBarImage(url) { [weak button] image in
1812
+ guard let button, let image else { return }
1813
+ let size = CGSize(width: 36, height: 36)
1814
+ let round = UIGraphicsImageRenderer(size: size).image { _ in
1815
+ UIBezierPath(ovalIn: CGRect(origin: .zero, size: size)).addClip()
1816
+ let scale = max(size.width / image.size.width, size.height / image.size.height)
1817
+ let drawn = CGSize(width: image.size.width * scale, height: image.size.height * scale)
1818
+ image.draw(in: CGRect(x: (size.width - drawn.width) / 2, y: (size.height - drawn.height) / 2, width: drawn.width, height: drawn.height))
1819
+ }
1820
+ button.setTitle(nil, for: .normal)
1821
+ button.setImage(round.withRenderingMode(.alwaysOriginal), for: .normal)
1822
+ }
1823
+ }
1824
+ let item = UIBarButtonItem(customView: button)
1825
+ // Keep the custom-view item compatible with the Xcode SDKs used by
1826
+ // Craft's release matrix; iOS 26-only bar-item APIs are not required
1827
+ // for the account surface itself.
1828
+ return item
1829
+ }
1830
+
1685
1831
  /// A bar picture, kept for the app's life so each screen does not fetch it again.
1686
1832
  private static let barImages = NSCache<NSURL, UIImage>()
1687
1833
 
@@ -1717,6 +1863,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1717
1863
  rootTopToSafeArea?.isActive = !edgeToEdge
1718
1864
  rootTopToView?.isActive = edgeToEdge
1719
1865
  }
1866
+ scroll?.contentInsetAdjustmentBehavior = edgeToEdge ? .automatic : .never
1720
1867
  guard scroll !== trackedScrollView else { return }
1721
1868
  trackedScrollView = scroll
1722
1869
  setContentScrollView(scroll, for: .top)
@@ -1747,6 +1894,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1747
1894
  ) { [weak self] notification in
1748
1895
  self?.applyKeyboardSafeArea(notification, hidden: true)
1749
1896
  })
1897
+ lifecycleObservers.append(NotificationCenter.default.addObserver(
1898
+ forName: CraftSnapshots.changed,
1899
+ object: nil,
1900
+ queue: .main
1901
+ ) { [weak self] notification in
1902
+ guard let name = notification.userInfo?["name"] as? String else { return }
1903
+ self?.send(type: "SNAPSHOT_CHANGED", payload: ["name": name])
1904
+ })
1750
1905
  sendAppState(CraftNativeActions.currentAppState())
1751
1906
  deepLinkListener = DeepLinkManager.shared.addNativeListener { [weak self] url, initial in
1752
1907
  guard let self = self, self.navigationController?.topViewController === self else { return }
@@ -1826,6 +1981,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1826
1981
  "version": craftNativeMutationProtocolVersion,
1827
1982
  ]
1828
1983
  if let operationIndex = failure.operationIndex { error["operationIndex"] = operationIndex }
1984
+ // In the log as well: the screen redraws itself on this, so
1985
+ // nothing on screen says a batch was refused.
1986
+ CraftNativeConsole.write("error", category: screenName(), message: "mutation \(error["batchId"] ?? "") refused: \(failure.code) \(failure.message)")
1829
1987
  send(type: "MUTATION_ERROR", payload: error)
1830
1988
  } catch {
1831
1989
  send(type: "MUTATION_ERROR", payload: [
@@ -1845,6 +2003,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1845
2003
  if type == "NAVIGATE" {
1846
2004
  navigation.pushViewController(next, animated: true)
1847
2005
  } else {
2006
+ disposeRuntime()
1848
2007
  navigation.setViewControllers(Array(navigation.viewControllers.dropLast()) + [next], animated: true)
1849
2008
  }
1850
2009
  case "NAVIGATION_SET_OPTIONS":
@@ -2144,7 +2303,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2144
2303
  case "Modal":
2145
2304
  let modal = result as! CraftNativeModalView
2146
2305
  modal.transparent = props["transparent"] as? Bool == true
2147
- let visible = props["visible"] as? Bool == true
2306
+ let visible = props["visible"] as? Bool == true && style["display"] as? String != "none"
2148
2307
  let modalID = ObjectIdentifier(modal)
2149
2308
  let wasVisible = modalVisibility[modalID]
2150
2309
  modalVisibility[modalID] = visible
@@ -2537,6 +2696,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2537
2696
  if let owner = node.protocolId { flatListOwners = flatListOwners.filter { $0.value != owner } }
2538
2697
  }
2539
2698
  let id = ObjectIdentifier(node.view)
2699
+ if let modal = node.view as? CraftNativeModalView { modal.resetForTeardown() }
2540
2700
  handlers.removeValue(forKey: id)
2541
2701
  pressInHandlers.removeValue(forKey: id)
2542
2702
  pressOutHandlers.removeValue(forKey: id)
@@ -2824,6 +2984,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2824
2984
  stack.axis = direction == "row" || direction == "row-reverse" ? .horizontal : .vertical
2825
2985
  stack.wrap = style["flexWrap"] as? String == "wrap" || style["flexWrap"] as? Bool == true
2826
2986
  stack.alignItems = style["alignItems"] as? String ?? "stretch"
2987
+ stack.alignContent = style["alignContent"] as? String ?? "flex-start"
2827
2988
  stack.justifyContent = style["justifyContent"] as? String ?? "flex-start"
2828
2989
  let gap = number(style["gap"]) ?? 0
2829
2990
  stack.gap = gap
@@ -3050,8 +3211,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3050
3211
  let uri = (source as? String) ?? (source as? [String: Any])?["uri"] as? String
3051
3212
  let id = ObjectIdentifier(view)
3052
3213
  guard let uri, !uri.isEmpty else {
3214
+ if imageSources[id] == "" { return }
3053
3215
  imageTasks.removeValue(forKey: id)?.cancel()
3054
- imageSources.removeValue(forKey: id)
3216
+ imageSources[id] = ""
3055
3217
  imageFailure(view, uri: nil, message: "Image source is missing")
3056
3218
  return
3057
3219
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.128",
3
+ "version": "0.0.132",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",
@@ -94,6 +94,6 @@
94
94
  "@stacksjs/clapp": "^0.2.0",
95
95
  "@types/bun": "^1.2.0",
96
96
  "@types/node": "^22.0.0",
97
- "better-dx": "^0.2.25"
97
+ "better-dx": "^0.2.38"
98
98
  }
99
99
  }