craft-native 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.
@@ -37,6 +37,7 @@ import java.net.URLConnection
37
37
  import java.util.UUID
38
38
  import java.util.concurrent.ConcurrentHashMap
39
39
  import java.util.concurrent.Executors
40
+ import java.util.concurrent.atomic.AtomicBoolean
40
41
  import kotlin.math.max
41
42
  import kotlin.math.roundToInt
42
43
 
@@ -86,6 +87,7 @@ internal class CraftNativeCapabilities(
86
87
  private val databaseExecutor = Executors.newSingleThreadExecutor()
87
88
  private val networkExecutor = Executors.newCachedThreadPool()
88
89
  private val cancelled = ConcurrentHashMap.newKeySet<String>()
90
+ private val closed = AtomicBoolean(false)
89
91
  private val fetchConnections = ConcurrentHashMap<String, HttpURLConnection>()
90
92
  private val fileRoot by lazy { File(context.cacheDir, "craft-files").apply { mkdirs() } }
91
93
  private var pendingFilePick: PendingFilePick? = null
@@ -135,6 +137,10 @@ internal class CraftNativeCapabilities(
135
137
  args: JSONArray,
136
138
  completion: (CraftNativeCapabilityResult) -> Unit,
137
139
  ) {
140
+ if (closed.get()) {
141
+ mainHandler.post { completion(failure("HOST_CLOSED", "The native capability host is closed")) }
142
+ return
143
+ }
138
144
  cancelled.remove(requestToken)
139
145
  if (version != CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION) {
140
146
  complete(requestToken, failure("UNSUPPORTED_VERSION", "Unsupported native capability protocol version"), completion)
@@ -150,6 +156,7 @@ internal class CraftNativeCapabilities(
150
156
  else bluetooth.perform(method, args) { complete(requestToken, it, completion) }
151
157
  }
152
158
  "Storage" -> mainHandler.post {
159
+ if (cancelled.remove(requestToken) || closed.get()) return@post
153
160
  val answer = runCatching { storage(method, args) }
154
161
  .getOrElse { failure("STORAGE_ERROR", it.message ?: "Storage operation failed") }
155
162
  complete(requestToken, answer, completion)
@@ -199,6 +206,8 @@ internal class CraftNativeCapabilities(
199
206
  }
200
207
 
201
208
  fun close() {
209
+ if (!closed.compareAndSet(false, true)) return
210
+ cancelled.clear()
202
211
  if (config.optBoolean("enableBluetooth")) bluetooth.disconnect()
203
212
  databaseExecutor.shutdownNow()
204
213
  networkExecutor.shutdownNow()
@@ -653,9 +662,9 @@ internal class CraftNativeCapabilities(
653
662
  completion: (CraftNativeCapabilityResult) -> Unit,
654
663
  ) {
655
664
  if (android.os.Looper.myLooper() == android.os.Looper.getMainLooper()) {
656
- if (!cancelled.remove(requestToken)) completion(result)
665
+ if (!cancelled.remove(requestToken) && !closed.get()) completion(result)
657
666
  } else mainHandler.post {
658
- if (!cancelled.remove(requestToken)) completion(result)
667
+ if (!cancelled.remove(requestToken) && !closed.get()) completion(result)
659
668
  }
660
669
  }
661
670
 
package/dist/cli.js CHANGED
@@ -245,6 +245,10 @@ function macOSAppStoreRuntimePath(providerPath) {
245
245
  const runtime = basename(providerPath) === "craft-app-store" ? providerPath : join2(dirname(providerPath), "craft-app-store");
246
246
  if (!existsSync4(runtime))
247
247
  throw new Error("App Store packaging requires Pantry\u2019s craft-app-store runtime. Upgrade Craft with Pantry before building.");
248
+ const bytes = readFileSync2(runtime);
249
+ const privateMarkers = ["MenuBarClientCore", "MBAssessmentMode", "developerExtrasEnabled", "javaScriptCanAccessClipboard", "drawsBackground"];
250
+ if (privateMarkers.some((marker) => bytes.includes(Buffer.from(marker))))
251
+ throw new Error("App Store packaging refused a runtime that includes private platform APIs.");
248
252
  const probe = spawnSync(runtime, ["--distribution"], { encoding: "utf8", timeout: 1e4 });
249
253
  if (probe.status !== 0 || probe.stdout.trim() !== "app-store")
250
254
  throw new Error("App Store packaging refused a runtime that includes private platform APIs.");
@@ -3018,7 +3022,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3018
3022
  `);
3019
3023
  }
3020
3024
  // package.json
3021
- var version = "0.0.338";
3025
+ var version = "0.0.341";
3022
3026
 
3023
3027
  // bin/cli.ts
3024
3028
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -375,7 +375,7 @@ function craftBinaryNotFoundMessage(triedPath) {
375
375
  `);
376
376
  }
377
377
  // package.json
378
- var version = "0.0.338";
378
+ var version = "0.0.341";
379
379
 
380
380
  // src/sdk-version.ts
381
381
  var SDK_VERSION = version;
@@ -611,6 +611,10 @@ function macOSAppStoreRuntimePath(providerPath) {
611
611
  const runtime = import_path.basename(providerPath) === "craft-app-store" ? providerPath : import_path.join(import_path.dirname(providerPath), "craft-app-store");
612
612
  if (!import_fs.existsSync(runtime))
613
613
  throw new Error("App Store packaging requires Pantry’s craft-app-store runtime. Upgrade Craft with Pantry before building.");
614
+ const bytes = import_fs.readFileSync(runtime);
615
+ const privateMarkers = ["MenuBarClientCore", "MBAssessmentMode", "developerExtrasEnabled", "javaScriptCanAccessClipboard", "drawsBackground"];
616
+ if (privateMarkers.some((marker) => bytes.includes(Buffer.from(marker))))
617
+ throw new Error("App Store packaging refused a runtime that includes private platform APIs.");
614
618
  const probe = import_child_process.spawnSync(runtime, ["--distribution"], { encoding: "utf8", timeout: 1e4 });
615
619
  if (probe.status !== 0 || probe.stdout.trim() !== "app-store")
616
620
  throw new Error("App Store packaging refused a runtime that includes private platform APIs.");
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.338";
120
+ var version = "0.0.341";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -367,6 +367,10 @@ function macOSAppStoreRuntimePath(providerPath) {
367
367
  const runtime = basename(providerPath) === "craft-app-store" ? providerPath : join2(dirname(providerPath), "craft-app-store");
368
368
  if (!existsSync3(runtime))
369
369
  throw new Error("App Store packaging requires Pantry\u2019s craft-app-store runtime. Upgrade Craft with Pantry before building.");
370
+ const bytes = readFileSync(runtime);
371
+ const privateMarkers = ["MenuBarClientCore", "MBAssessmentMode", "developerExtrasEnabled", "javaScriptCanAccessClipboard", "drawsBackground"];
372
+ if (privateMarkers.some((marker) => bytes.includes(Buffer.from(marker))))
373
+ throw new Error("App Store packaging refused a runtime that includes private platform APIs.");
370
374
  const probe = spawnSync(runtime, ["--distribution"], { encoding: "utf8", timeout: 1e4 });
371
375
  if (probe.status !== 0 || probe.stdout.trim() !== "app-store")
372
376
  throw new Error("App Store packaging refused a runtime that includes private platform APIs.");
@@ -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 { complete(requestToken, storage(method: method, args: args), completion) }
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
- contentInset = UIEdgeInsets(
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
- scrollIndicatorInsets = contentInset
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
- self.columns = max(1, columns)
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
- let dataIndices = items.indices.filter { !items[$0].isChrome }
642
- guard !dataIndices.isEmpty,
643
- let lastDataOrdinal = dataIndices.lastIndex(where: { $0 <= lastVisible }) else { return }
644
- let remaining = dataIndices.count - lastDataOrdinal - 1
645
- let thresholdItems = max(1, Int(ceil(Double(dataIndices.count) * endReachedThreshold)))
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
- CraftNativeScriptThread.run(label: "unmount \(route)") { context.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: []) }
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
- do { return try CraftNativeActions.storageJSON(forKey: key) }
6474
- catch {
6475
- if let context = JSContext.current() {
6476
- context.exception = JSValue(newErrorFromMessage: "STORAGE_ERROR: Unable to read stored value", in: 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
- CraftNativeActions.setStorageJSON(value.isNull || value.isUndefined ? nil : value.toString(), forKey: key)
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.338",
3
+ "version": "0.0.341",
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>",