craft-native 0.0.169 → 0.0.171

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.
@@ -109,7 +109,7 @@ internal class CraftNativeServices(
109
109
  "Speech" -> speech(method, args, done)
110
110
  "Location" -> location(method, args, done)
111
111
  "Media" -> when (method) {
112
- "keep" -> CraftNativeMedia.keep(context, args.optString(0), args.optJSONObject(1)?.optJSONObject("headers") ?: JSONObject(), done)
112
+ "keep" -> CraftNativeMedia.keep(context, args.optString(0), args.optJSONObject(1)?.optJSONObject("headers") ?: JSONObject(), args.optJSONObject(1)?.optBoolean("cellular", true) ?: true, done)
113
113
  "path" -> done(success(CraftNativeMedia.local(context, args.optString(0))?.absolutePath ?: JSONObject.NULL))
114
114
  "prune" -> {
115
115
  val keep = args.optJSONArray(0) ?: JSONArray()
@@ -808,12 +808,21 @@ internal object CraftNativeMedia {
808
808
 
809
809
  private fun describe(address: String, file: File) = JSONObject().put("ok", true).put("url", address).put("path", file.absolutePath).put("size", file.length())
810
810
 
811
- fun keep(context: Context, address: String, headers: JSONObject, done: (CraftNativeCapabilityResult) -> Unit) {
811
+ /**
812
+ * `cellular = false` keeps it off a metered connection (mobile data, a
813
+ * hotspot): nothing is fetched and the answer is `{ ok: false, reason:
814
+ * "cellular" }`, so the screen can say it is waiting for Wi-Fi. A copy
815
+ * already kept still answers, whatever the connection.
816
+ */
817
+ fun keep(context: Context, address: String, headers: JSONObject, cellular: Boolean, done: (CraftNativeCapabilityResult) -> Unit) {
812
818
  val uri = runCatching { Uri.parse(address) }.getOrNull()
813
819
  if (uri == null || uri.scheme?.lowercase() !in setOf("http", "https") || uri.host.isNullOrBlank()) {
814
820
  return done(CraftNativeCapabilityResult(code = "INVALID_ARGUMENT", message = "media.keep needs an absolute http or https URL"))
815
821
  }
816
822
  local(context, address)?.let { return done(CraftNativeCapabilityResult(data = describe(address, it))) }
823
+ if (!cellular && metered(context)) {
824
+ return done(CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("reason", "cellular")))
825
+ }
817
826
  synchronized(inFlight) {
818
827
  inFlight[address]?.let { waiting ->
819
828
  waiting.add(done)
@@ -851,6 +860,12 @@ internal object CraftNativeMedia {
851
860
  }
852
861
  }
853
862
 
863
+ /** Whether the connection now is one the person pays for by the byte. */
864
+ private fun metered(context: Context): Boolean = runCatching {
865
+ val connectivity = context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
866
+ connectivity.isActiveNetworkMetered
867
+ }.getOrDefault(false)
868
+
854
869
  /** Removes every copy whose address is not in `keep`; answers how many. */
855
870
  fun prune(context: Context, keep: List<String>): Int {
856
871
  val wanted = keep.map { file(context, it).name }.toSet()
package/dist/cli.js CHANGED
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3303
3303
  `);
3304
3304
  }
3305
3305
  // package.json
3306
- var version = "0.0.169";
3306
+ var version = "0.0.171";
3307
3307
 
3308
3308
  // bin/cli.ts
3309
3309
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.169";
313
+ var version = "0.0.171";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
58
58
  `);
59
59
  }
60
60
  // package.json
61
- var version = "0.0.169";
61
+ var version = "0.0.171";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -647,6 +647,12 @@ enum CraftNativeActions {
647
647
  /// offline, fetched once (a copy already there answers at once, and two
648
648
  /// screens asking at the same time share one download). A refused answer
649
649
  /// (not 2xx) keeps nothing and says its status. Cancelled like fetch.
650
+ ///
651
+ /// `cellular: false` keeps it off a metered connection: iOS refuses the
652
+ /// download on cellular, a personal hotspot and Low Data Mode, and the
653
+ /// answer is `{ ok: false, reason: "cellular" }` straight away, so the
654
+ /// screen can say it is waiting for Wi-Fi instead of failing. A copy
655
+ /// already kept still answers, whatever the connection.
650
656
  private static func keepMedia(
651
657
  requestToken: String,
652
658
  args: [Any],
@@ -675,12 +681,21 @@ enum CraftNativeActions {
675
681
  request.setValue(value, forHTTPHeaderField: name)
676
682
  }
677
683
  }
684
+ if options["cellular"] as? Bool == false {
685
+ request.allowsExpensiveNetworkAccess = false
686
+ request.allowsConstrainedNetworkAccess = false
687
+ }
678
688
  if CraftNativeMedia.join(address, { complete(requestToken, $0, completion) }) { return }
679
689
  let task = fetchSession.downloadTask(with: request) { location, response, error in
680
690
  cancellationLock.lock()
681
691
  fetchTasks.removeValue(forKey: requestToken)
682
692
  cancellationLock.unlock()
683
693
  if let error = error {
694
+ // Refused only because the connection is metered: not a failure, a wait.
695
+ if let reason = (error as? URLError)?.networkUnavailableReason, reason == .expensive || reason == .constrained {
696
+ CraftNativeMedia.finish(address, .success(["ok": false, "url": address, "reason": "cellular"]))
697
+ return
698
+ }
684
699
  let code = (error as? URLError)?.code == .timedOut ? "TIMEOUT" : "NETWORK_ERROR"
685
700
  CraftNativeMedia.finish(address, failure(code, error.localizedDescription))
686
701
  return
@@ -150,6 +150,11 @@ final class CraftNativeMutationDocument {
150
150
  materialize(id, nodes: nodes)
151
151
  }
152
152
 
153
+ /// The committed parent of a node: nil for the root or a node it does not hold.
154
+ func parentId(of id: String) -> String? {
155
+ nodes[id]?.parent
156
+ }
157
+
153
158
  private func createNode(
154
159
  _ operation: [String: Any],
155
160
  nodes: inout [String: Node],
@@ -291,8 +296,21 @@ final class CraftNativeMutationDocument {
291
296
  return values.compactMap { $0 as? String }
292
297
  }
293
298
 
299
+ /// Every type the renderer draws children of (CraftNativeScreen's
300
+ /// reconcile), by the names it accepts (`canonicalType`, so `svg` is
301
+ /// `Svg`). Only View, SafeAreaView and ScrollView used to be here, so a
302
+ /// drawing revealed after the first paint (an `:if` around an `<svg>`)
303
+ /// had its shapes refused as INVALID_PARENT, and the whole screen was
304
+ /// drawn again; Android's host already took the same list.
305
+ static let containerTypes: Set<String> = [
306
+ "View", "SafeAreaView", "ScrollView", "KeyboardAvoidingView", "Modal", "Pressable",
307
+ "TouchableOpacity", "TouchableHighlight", "ImageBackground", "FlatList", "SectionList",
308
+ "Fragment", "Svg", "G",
309
+ ]
310
+ private static let containerTypeKeys = Set(containerTypes.map { $0.lowercased() })
311
+
294
312
  private func acceptsChildren(_ type: String) -> Bool {
295
- type == "View" || type == "SafeAreaView" || type == "ScrollView"
313
+ Self.containerTypeKeys.contains(type.lowercased())
296
314
  }
297
315
 
298
316
  private func descendants(of id: String, nodes: [String: Node]) -> Set<String> {
@@ -3816,7 +3816,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3816
3816
  let focused = firstResponder(in: rootStack) ?? lastFocusedInput
3817
3817
  var applied = Set<String>()
3818
3818
  for id in ids {
3819
- let target = flatListOwners[id] ?? svgOwners[id] ?? id
3819
+ guard let target = drawnTarget(id) else { return false }
3820
3820
  guard applied.insert(target).inserted else { continue }
3821
3821
  guard let document = mutationDocument.node(target), let previous = renderedNode(target) else { return false }
3822
3822
  let next = reconcile(document, identity: previous.identity, path: target, previous: previous)
@@ -3828,6 +3828,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3828
3828
  return true
3829
3829
  }
3830
3830
 
3831
+ /// The drawn node a change to `id` redraws: the list or drawing a row or
3832
+ /// shape belongs to, else the node itself. A node this batch created is
3833
+ /// not drawn yet (a drawing an `:if` just revealed, its shapes inserted
3834
+ /// into it), so its nearest drawn ancestor draws it, rather than the whole
3835
+ /// screen being reconciled again for want of it.
3836
+ private func drawnTarget(_ id: String) -> String? {
3837
+ var current: String? = id
3838
+ while let candidate = current {
3839
+ let target = flatListOwners[candidate] ?? svgOwners[candidate] ?? candidate
3840
+ if renderedNode(target) != nil { return target }
3841
+ current = mutationDocument.parentId(of: candidate)
3842
+ }
3843
+ return nil
3844
+ }
3845
+
3831
3846
  private func renderedNode(_ id: String, below node: RenderedNode? = nil) -> RenderedNode? {
3832
3847
  guard let node = node ?? renderedRoot else { return nil }
3833
3848
  if node.protocolId == id { return node }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.169",
3
+ "version": "0.0.171",
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>",