craft-native 0.0.340 → 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
@@ -3022,7 +3022,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3022
3022
  `);
3023
3023
  }
3024
3024
  // package.json
3025
- var version = "0.0.340";
3025
+ var version = "0.0.341";
3026
3026
 
3027
3027
  // bin/cli.ts
3028
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.340";
378
+ var version = "0.0.341";
379
379
 
380
380
  // src/sdk-version.ts
381
381
  var SDK_VERSION = version;
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.340";
120
+ var version = "0.0.341";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -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 {
@@ -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.340",
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>",