craft-native 0.0.127 → 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
788
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)
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)
@@ -1662,9 +1751,95 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1662
1751
  item.accessibilityLabel = id
1663
1752
  }
1664
1753
  if let tint = (spec["color"] as? String).flatMap({ UIColor(hex: $0) }) { item.tintColor = tint }
1754
+ // `image`: a picture (an https URL, the account's own photo), drawn
1755
+ // round as iOS draws an account button. The symbol or title shows
1756
+ // until it has loaded, and stays if it cannot be.
1757
+ if let source = spec["image"] as? String, let url = URL(string: source), url.scheme == "https" {
1758
+ loadBarImage(url) { [weak item] image in
1759
+ guard let item, let image else { return }
1760
+ let size = CGSize(width: 32, height: 32)
1761
+ let round = UIGraphicsImageRenderer(size: size).image { _ in
1762
+ UIBezierPath(ovalIn: CGRect(origin: .zero, size: size)).addClip()
1763
+ let scale = max(size.width / image.size.width, size.height / image.size.height)
1764
+ let drawn = CGSize(width: image.size.width * scale, height: image.size.height * scale)
1765
+ image.draw(in: CGRect(x: (size.width - drawn.width) / 2, y: (size.height - drawn.height) / 2, width: drawn.width, height: drawn.height))
1766
+ }
1767
+ item.title = nil
1768
+ item.image = round.withRenderingMode(.alwaysOriginal)
1769
+ }
1770
+ }
1771
+ return item
1772
+ }
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.
1665
1828
  return item
1666
1829
  }
1667
1830
 
1831
+ /// A bar picture, kept for the app's life so each screen does not fetch it again.
1832
+ private static let barImages = NSCache<NSURL, UIImage>()
1833
+
1834
+ private func loadBarImage(_ url: URL, _ done: @escaping (UIImage?) -> Void) {
1835
+ if let cached = Self.barImages.object(forKey: url as NSURL) { done(cached); return }
1836
+ URLSession.shared.dataTask(with: url) { data, _, _ in
1837
+ let image = data.flatMap(UIImage.init(data:))
1838
+ if let image { Self.barImages.setObject(image, forKey: url as NSURL) }
1839
+ DispatchQueue.main.async { done(image) }
1840
+ }.resume()
1841
+ }
1842
+
1668
1843
  /// The first vertical scroll view of the screen drives the bar: a large
1669
1844
  /// title collapses as it scrolls, and the bar's background follows it.
1670
1845
  private func trackContentScrollView() {
@@ -1688,6 +1863,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1688
1863
  rootTopToSafeArea?.isActive = !edgeToEdge
1689
1864
  rootTopToView?.isActive = edgeToEdge
1690
1865
  }
1866
+ scroll?.contentInsetAdjustmentBehavior = edgeToEdge ? .automatic : .never
1691
1867
  guard scroll !== trackedScrollView else { return }
1692
1868
  trackedScrollView = scroll
1693
1869
  setContentScrollView(scroll, for: .top)
@@ -1718,6 +1894,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1718
1894
  ) { [weak self] notification in
1719
1895
  self?.applyKeyboardSafeArea(notification, hidden: true)
1720
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
+ })
1721
1905
  sendAppState(CraftNativeActions.currentAppState())
1722
1906
  deepLinkListener = DeepLinkManager.shared.addNativeListener { [weak self] url, initial in
1723
1907
  guard let self = self, self.navigationController?.topViewController === self else { return }
@@ -1797,6 +1981,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1797
1981
  "version": craftNativeMutationProtocolVersion,
1798
1982
  ]
1799
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)")
1800
1987
  send(type: "MUTATION_ERROR", payload: error)
1801
1988
  } catch {
1802
1989
  send(type: "MUTATION_ERROR", payload: [
@@ -1816,6 +2003,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1816
2003
  if type == "NAVIGATE" {
1817
2004
  navigation.pushViewController(next, animated: true)
1818
2005
  } else {
2006
+ disposeRuntime()
1819
2007
  navigation.setViewControllers(Array(navigation.viewControllers.dropLast()) + [next], animated: true)
1820
2008
  }
1821
2009
  case "NAVIGATION_SET_OPTIONS":
@@ -2115,7 +2303,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2115
2303
  case "Modal":
2116
2304
  let modal = result as! CraftNativeModalView
2117
2305
  modal.transparent = props["transparent"] as? Bool == true
2118
- let visible = props["visible"] as? Bool == true
2306
+ let visible = props["visible"] as? Bool == true && style["display"] as? String != "none"
2119
2307
  let modalID = ObjectIdentifier(modal)
2120
2308
  let wasVisible = modalVisibility[modalID]
2121
2309
  modalVisibility[modalID] = visible
@@ -2508,6 +2696,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2508
2696
  if let owner = node.protocolId { flatListOwners = flatListOwners.filter { $0.value != owner } }
2509
2697
  }
2510
2698
  let id = ObjectIdentifier(node.view)
2699
+ if let modal = node.view as? CraftNativeModalView { modal.resetForTeardown() }
2511
2700
  handlers.removeValue(forKey: id)
2512
2701
  pressInHandlers.removeValue(forKey: id)
2513
2702
  pressOutHandlers.removeValue(forKey: id)
@@ -2795,6 +2984,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2795
2984
  stack.axis = direction == "row" || direction == "row-reverse" ? .horizontal : .vertical
2796
2985
  stack.wrap = style["flexWrap"] as? String == "wrap" || style["flexWrap"] as? Bool == true
2797
2986
  stack.alignItems = style["alignItems"] as? String ?? "stretch"
2987
+ stack.alignContent = style["alignContent"] as? String ?? "flex-start"
2798
2988
  stack.justifyContent = style["justifyContent"] as? String ?? "flex-start"
2799
2989
  let gap = number(style["gap"]) ?? 0
2800
2990
  stack.gap = gap
@@ -3021,8 +3211,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3021
3211
  let uri = (source as? String) ?? (source as? [String: Any])?["uri"] as? String
3022
3212
  let id = ObjectIdentifier(view)
3023
3213
  guard let uri, !uri.isEmpty else {
3214
+ if imageSources[id] == "" { return }
3024
3215
  imageTasks.removeValue(forKey: id)?.cancel()
3025
- imageSources.removeValue(forKey: id)
3216
+ imageSources[id] = ""
3026
3217
  imageFailure(view, uri: nil, message: "Image source is missing")
3027
3218
  return
3028
3219
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.127",
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
  }