@craft-native/ios 0.0.338 → 0.0.341
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/package.json
CHANGED
|
@@ -203,7 +203,13 @@ enum CraftNativeActions {
|
|
|
203
203
|
case ("Clipboard", "read"):
|
|
204
204
|
sync = perform(action: "clipboardRead", body: [:], config: config)
|
|
205
205
|
case ("Storage", _):
|
|
206
|
-
DispatchQueue.main.async {
|
|
206
|
+
DispatchQueue.main.async {
|
|
207
|
+
cancellationLock.lock()
|
|
208
|
+
let cancelled = cancelledRequests.remove(requestToken) != nil
|
|
209
|
+
cancellationLock.unlock()
|
|
210
|
+
guard !cancelled else { return }
|
|
211
|
+
complete(requestToken, storage(method: method, args: args), completion)
|
|
212
|
+
}
|
|
207
213
|
return
|
|
208
214
|
case ("SecureStorage", _):
|
|
209
215
|
guard config.enableSecureStorage else {
|
|
@@ -60,7 +60,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
60
60
|
/// The row this cell showed is gone; its views stay in the cell, and
|
|
61
61
|
/// the next row it shows reuses them when it has the same shape.
|
|
62
62
|
func recycle() {
|
|
63
|
-
if let identity = representedIdentity { onRecycle?(identity) }
|
|
63
|
+
if let identity = representedIdentity, showsHostedView { onRecycle?(identity) }
|
|
64
64
|
representedIdentity = nil
|
|
65
65
|
onRecycle = nil
|
|
66
66
|
}
|
|
@@ -81,6 +81,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
81
81
|
// since moved to another cell is that cell's now, and pulling it
|
|
82
82
|
// from there blanked a row on screen.
|
|
83
83
|
if let shown = hostedView, shown !== view, shown.superview === contentView { shown.removeFromSuperview() }
|
|
84
|
+
NSLayoutConstraint.deactivate(hostedConstraints)
|
|
84
85
|
hostedView = view
|
|
85
86
|
view.translatesAutoresizingMaskIntoConstraints = false
|
|
86
87
|
contentView.addSubview(view)
|
|
@@ -110,6 +111,8 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
110
111
|
func releaseHostedView() {
|
|
111
112
|
recycle()
|
|
112
113
|
if let shown = hostedView, shown.superview === contentView { shown.removeFromSuperview() }
|
|
114
|
+
NSLayoutConstraint.deactivate(hostedConstraints)
|
|
115
|
+
hostedConstraints = []
|
|
113
116
|
hostedView = nil
|
|
114
117
|
}
|
|
115
118
|
|
|
@@ -195,8 +198,11 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
195
198
|
private var endReachedThreshold = 0.1
|
|
196
199
|
private var endReachedSignature: String?
|
|
197
200
|
private var dataContentSignature = ""
|
|
201
|
+
private var dataOrdinals: [Int] = []
|
|
202
|
+
private var dataItemCount = 0
|
|
198
203
|
private var columns = 1
|
|
199
204
|
private var isApplyingSnapshot = false
|
|
205
|
+
private var snapshotNeedsReset = false
|
|
200
206
|
private var pendingApply: (() -> Void)?
|
|
201
207
|
private var pendingThemeRefresh = false
|
|
202
208
|
private let nativeRefreshControl = UIRefreshControl()
|
|
@@ -224,6 +230,10 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
224
230
|
backgroundColor = .clear
|
|
225
231
|
alwaysBounceVertical = true
|
|
226
232
|
delegate = self
|
|
233
|
+
// The renderer keeps one live view per keyed row. UIKit's speculative
|
|
234
|
+
// cell prefetch can otherwise move that view out of its visible cell.
|
|
235
|
+
// UICollectionView still recycles and self-sizes the visible window.
|
|
236
|
+
isPrefetchingEnabled = false
|
|
227
237
|
keyboardTapGesture.cancelsTouchesInView = false
|
|
228
238
|
keyboardTapGesture.delegate = self
|
|
229
239
|
keyboardTapGesture.addTarget(self, action: #selector(keyboardTap))
|
|
@@ -382,13 +392,15 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
382
392
|
let padding = nonNegative(raw?["padding"]) ?? 0
|
|
383
393
|
let horizontal = nonNegative(raw?["paddingHorizontal"]) ?? padding
|
|
384
394
|
let vertical = nonNegative(raw?["paddingVertical"]) ?? padding
|
|
385
|
-
|
|
395
|
+
let inset = UIEdgeInsets(
|
|
386
396
|
top: nonNegative(raw?["paddingTop"]) ?? vertical,
|
|
387
397
|
left: nonNegative(raw?["paddingLeft"]) ?? horizontal,
|
|
388
398
|
bottom: nonNegative(raw?["paddingBottom"]) ?? vertical,
|
|
389
399
|
right: nonNegative(raw?["paddingRight"]) ?? horizontal
|
|
390
400
|
)
|
|
391
|
-
|
|
401
|
+
guard contentInset != inset else { return }
|
|
402
|
+
contentInset = inset
|
|
403
|
+
scrollIndicatorInsets = inset
|
|
392
404
|
collectionViewLayout.invalidateLayout()
|
|
393
405
|
}
|
|
394
406
|
|
|
@@ -479,7 +491,9 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
479
491
|
self.removeItem = removeItem
|
|
480
492
|
self.endReached = endReached
|
|
481
493
|
self.endReachedThreshold = max(0, endReachedThreshold)
|
|
482
|
-
|
|
494
|
+
let nextColumns = max(1, columns)
|
|
495
|
+
let layoutChanged = self.columns != nextColumns || flowLayout.scrollDirection != (horizontal ? .horizontal : .vertical)
|
|
496
|
+
self.columns = nextColumns
|
|
483
497
|
|
|
484
498
|
layoutIfNeeded()
|
|
485
499
|
let oldItems = itemsByIdentity
|
|
@@ -496,6 +510,19 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
496
510
|
return Item(identity: identity, node: node, signature: signature, isChrome: role != nil && role != "item")
|
|
497
511
|
}
|
|
498
512
|
if inverted { next.reverse() }
|
|
513
|
+
// Reapplying an identical keyed tree during a timer or badge update
|
|
514
|
+
// must not reconfigure cells, invalidate self-sizing, or reset a drag.
|
|
515
|
+
if !snapshotNeedsReset, !layoutChanged, next.count == items.count,
|
|
516
|
+
zip(next, items).allSatisfy({ $0.identity == $1.identity && $0.signature == $1.signature }) {
|
|
517
|
+
finishApplyingSnapshot()
|
|
518
|
+
return
|
|
519
|
+
}
|
|
520
|
+
snapshotNeedsReset = false
|
|
521
|
+
dataItemCount = 0
|
|
522
|
+
dataOrdinals = next.map { item in
|
|
523
|
+
if !item.isChrome { dataItemCount += 1 }
|
|
524
|
+
return dataItemCount - 1
|
|
525
|
+
}
|
|
499
526
|
items = next
|
|
500
527
|
itemsByIdentity = Dictionary(uniqueKeysWithValues: next.map { ($0.identity, $0) })
|
|
501
528
|
|
|
@@ -504,7 +531,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
504
531
|
alwaysBounceVertical = !horizontal
|
|
505
532
|
showsHorizontalScrollIndicator = horizontal
|
|
506
533
|
showsVerticalScrollIndicator = !horizontal
|
|
507
|
-
flowLayout.invalidateLayout()
|
|
534
|
+
if layoutChanged { flowLayout.invalidateLayout() }
|
|
508
535
|
|
|
509
536
|
let nextIds = Set(itemsByIdentity.keys)
|
|
510
537
|
for identity in oldItems.keys where !nextIds.contains(identity) { removeItem(identity) }
|
|
@@ -550,9 +577,12 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
550
577
|
}
|
|
551
578
|
|
|
552
579
|
func discardAll() {
|
|
580
|
+
snapshotNeedsReset = true
|
|
553
581
|
visibleCells.compactMap { $0 as? Cell }.forEach { $0.releaseHostedView() }
|
|
554
582
|
itemsByIdentity.keys.forEach { recycleItem?($0) }
|
|
555
583
|
items = []
|
|
584
|
+
dataOrdinals = []
|
|
585
|
+
dataItemCount = 0
|
|
556
586
|
itemsByIdentity = [:]
|
|
557
587
|
renderItem = nil
|
|
558
588
|
recycleItem = nil
|
|
@@ -638,11 +668,11 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
638
668
|
guard !isApplyingSnapshot, pendingApply == nil,
|
|
639
669
|
let endReached = endReached,
|
|
640
670
|
let lastVisible = indexPathsForVisibleItems.map(\.item).max() else { return }
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
let remaining =
|
|
645
|
-
let thresholdItems = max(1, Int(ceil(Double(
|
|
671
|
+
guard dataItemCount > 0, dataOrdinals.indices.contains(lastVisible) else { return }
|
|
672
|
+
let lastDataOrdinal = dataOrdinals[lastVisible]
|
|
673
|
+
guard lastDataOrdinal >= 0 else { return }
|
|
674
|
+
let remaining = dataItemCount - lastDataOrdinal - 1
|
|
675
|
+
let thresholdItems = max(1, Int(ceil(Double(dataItemCount) * endReachedThreshold)))
|
|
646
676
|
guard remaining <= thresholdItems else { return }
|
|
647
677
|
let signature = dataContentSignature
|
|
648
678
|
guard endReachedSignature != signature else { return }
|
|
@@ -15,6 +15,45 @@ import UIKit
|
|
|
15
15
|
|
|
16
16
|
private let craftNativeCapabilityTimeoutMilliseconds = 30_000
|
|
17
17
|
|
|
18
|
+
/// A retained JavaScript context must lose synchronous storage access when
|
|
19
|
+
/// its screen closes. Only the actual synchronous unmount callback may finish
|
|
20
|
+
/// its cleanup; queued script work runs after that allowance ends.
|
|
21
|
+
private final class CraftNativeStorageLifetime {
|
|
22
|
+
private let lock = NSLock()
|
|
23
|
+
private var closed = false
|
|
24
|
+
private var unmounting = false
|
|
25
|
+
|
|
26
|
+
func close() {
|
|
27
|
+
lock.lock()
|
|
28
|
+
closed = true
|
|
29
|
+
lock.unlock()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func unmount(_ work: () -> Void) {
|
|
33
|
+
lock.lock()
|
|
34
|
+
unmounting = true
|
|
35
|
+
lock.unlock()
|
|
36
|
+
defer {
|
|
37
|
+
lock.lock()
|
|
38
|
+
unmounting = false
|
|
39
|
+
lock.unlock()
|
|
40
|
+
}
|
|
41
|
+
work()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func access<T>(closedValue: T, _ work: () -> T) -> T {
|
|
45
|
+
lock.lock()
|
|
46
|
+
defer { lock.unlock() }
|
|
47
|
+
guard !closed || unmounting else {
|
|
48
|
+
if let context = JSContext.current() {
|
|
49
|
+
context.exception = JSValue(newErrorFromMessage: "SCREEN_CLOSED: The native screen is closed", in: context)
|
|
50
|
+
}
|
|
51
|
+
return closedValue
|
|
52
|
+
}
|
|
53
|
+
return work()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
18
57
|
/// Foundation's `NSNumber(0/1) is Bool` is true even for JSON numbers.
|
|
19
58
|
/// Check the Core Foundation identity so real booleans stay invalid numeric
|
|
20
59
|
/// props while zero lengths, flex factors and opacity 1 remain numbers.
|
|
@@ -5751,6 +5790,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5751
5790
|
weak var hybridEvents: CraftHybridScreenEvents?
|
|
5752
5791
|
private var hybridRendered = false
|
|
5753
5792
|
private var runtimeDisposed = false
|
|
5793
|
+
private let storageLifetime = CraftNativeStorageLifetime()
|
|
5754
5794
|
/// The app script (`nativeApp`, CraftHybrid.swift): a bundle route with
|
|
5755
5795
|
/// no screen, loaded once and never shown, that runs for the app's life.
|
|
5756
5796
|
/// It hears every `APP_STATE` and the app's events (`CRAFT_EVENT`), no
|
|
@@ -5998,6 +6038,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5998
6038
|
private func disposeRuntime() {
|
|
5999
6039
|
guard !runtimeDisposed else { return }
|
|
6000
6040
|
runtimeDisposed = true
|
|
6041
|
+
storageLifetime.close()
|
|
6042
|
+
pendingCapabilityDeadlines.values.forEach { $0.cancel() }
|
|
6043
|
+
pendingCapabilityDeadlines.removeAll()
|
|
6044
|
+
pendingCapabilityRequests.forEach { CraftNativeActions.cancel(requestToken: $0) }
|
|
6045
|
+
pendingCapabilityRequests.removeAll()
|
|
6001
6046
|
if let owner = voiceRecordingOwner {
|
|
6002
6047
|
voiceRecordingOwner = nil
|
|
6003
6048
|
CraftVoiceRecording.shared.perform(method: "cancel", args: [owner]) { _ in }
|
|
@@ -6007,7 +6052,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6007
6052
|
disposeRenderedTree()
|
|
6008
6053
|
let context = jsContext
|
|
6009
6054
|
let route = routeName ?? "screen"
|
|
6010
|
-
|
|
6055
|
+
let storageLifetime = self.storageLifetime
|
|
6056
|
+
CraftNativeScriptThread.run(label: "unmount \(route)") {
|
|
6057
|
+
storageLifetime.unmount { context.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: []) }
|
|
6058
|
+
}
|
|
6011
6059
|
audio.close()
|
|
6012
6060
|
sounds.close()
|
|
6013
6061
|
streams.close()
|
|
@@ -6469,17 +6517,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6469
6517
|
let log: @convention(block) (String, String, String) -> Void = { level, category, message in
|
|
6470
6518
|
CraftNativeConsole.write(level, category: category, message: message)
|
|
6471
6519
|
}
|
|
6520
|
+
let storageLifetime = self.storageLifetime
|
|
6472
6521
|
let storageGet: @convention(block) (String) -> String? = { key in
|
|
6473
|
-
|
|
6474
|
-
|
|
6475
|
-
|
|
6476
|
-
context
|
|
6522
|
+
storageLifetime.access(closedValue: nil) {
|
|
6523
|
+
do { return try CraftNativeActions.storageJSON(forKey: key) }
|
|
6524
|
+
catch {
|
|
6525
|
+
if let context = JSContext.current() {
|
|
6526
|
+
context.exception = JSValue(newErrorFromMessage: "STORAGE_ERROR: Unable to read stored value", in: context)
|
|
6527
|
+
}
|
|
6528
|
+
return nil
|
|
6477
6529
|
}
|
|
6478
|
-
return nil
|
|
6479
6530
|
}
|
|
6480
6531
|
}
|
|
6481
6532
|
let storageSet: @convention(block) (String, JSValue) -> Bool = { key, value in
|
|
6482
|
-
|
|
6533
|
+
storageLifetime.access(closedValue: false) {
|
|
6534
|
+
CraftNativeActions.setStorageJSON(value.isNull || value.isUndefined ? nil : value.toString(), forKey: key)
|
|
6535
|
+
}
|
|
6483
6536
|
}
|
|
6484
6537
|
let snapshotGet: @convention(block) (String) -> String? = { name in
|
|
6485
6538
|
CraftSnapshots.read(name)
|
|
@@ -7613,6 +7666,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7613
7666
|
navigationController?.popToRootViewController(animated: true)
|
|
7614
7667
|
}
|
|
7615
7668
|
case "API_REQUEST":
|
|
7669
|
+
guard !runtimeDisposed else { return }
|
|
7616
7670
|
guard let id = message["id"] as? String else { return }
|
|
7617
7671
|
let args = payload["args"] as? [Any] ?? []
|
|
7618
7672
|
let requestToken = "\(capabilityScope)/\(id)"
|
|
@@ -7682,7 +7736,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7682
7736
|
case "API_CANCEL":
|
|
7683
7737
|
guard let id = payload["requestId"] as? String else { return }
|
|
7684
7738
|
let requestToken = "\(capabilityScope)/\(id)"
|
|
7685
|
-
pendingCapabilityRequests.remove(requestToken)
|
|
7739
|
+
guard pendingCapabilityRequests.remove(requestToken) != nil else { return }
|
|
7686
7740
|
pendingCapabilityDeadlines.removeValue(forKey: requestToken)?.cancel()
|
|
7687
7741
|
screenRequestCancellers.removeValue(forKey: requestToken)?()
|
|
7688
7742
|
CraftNativeActions.cancel(requestToken: requestToken)
|