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.
- package/dist/android/templates/CraftNativeCapabilities.kt.template +103 -1
- package/dist/android/templates/CraftNativeMutation.kt.template +12 -3
- package/dist/android/templates/MainActivityNative.kt.template +262 -57
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +51 -7
- package/dist/ios/templates/CraftHybrid.swift +3 -6
- package/dist/ios/templates/CraftNativeActions.swift +5 -1
- package/dist/ios/templates/CraftNativeMutation.swift +10 -3
- package/dist/ios/templates/CraftNativeScreen.swift +173 -11
- package/package.json +2 -2
|
@@ -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 {
|
|
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
|
-
|
|
5813
|
-
|
|
5814
|
-
|
|
5815
|
-
|
|
5816
|
-
|
|
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
|
-
|
|
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 {
|
|
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)
|
|
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 =
|
|
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 {
|
|
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") {
|
|
199
|
-
if patch.keys.contains("style") {
|
|
200
|
-
if patch.keys.contains("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
|
-
|
|
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
|
|
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 +
|
|
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
|
-
|
|
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
|
-
|
|
783
|
-
|
|
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:
|
|
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
|
-
|
|
1328
|
-
|
|
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
|
|
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.
|
|
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.
|
|
97
|
+
"better-dx": "^0.2.38"
|
|
98
98
|
}
|
|
99
99
|
}
|