craft-native 0.0.224 → 0.0.225

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.
@@ -549,6 +549,7 @@ function writeNativeShellSources(output, packagePath, packageName, config) {
549
549
  };
550
550
  write("CraftBluetooth.kt.template", "CraftBluetooth.kt");
551
551
  write("CraftNativeShell.kt.template", "CraftNativeShell.kt");
552
+ write("CraftNativeImageCache.kt.template", "CraftNativeImageCache.kt");
552
553
  write("CraftNativeBackground.kt.template", "CraftNativeBackground.kt");
553
554
  write(config.enableHealthConnect ? "CraftNativeHealth.kt.template" : "CraftNativeHealthStub.kt.template", "CraftNativeHealth.kt");
554
555
  write("CraftNativeServices.kt.template", "CraftNativeServices.kt", (source) => source.replace("{{NATIVE_PUSH_IMPORTS}}", config.enablePushNotifications ? "import com.google.firebase.messaging.FirebaseMessaging" : "").replace("{{NATIVE_PUSH_REGISTER}}", config.enablePushNotifications ? ` try {
@@ -0,0 +1,141 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import java.io.File
4
+ import java.io.BufferedInputStream
5
+ import java.io.DataInputStream
6
+ import java.io.DataOutputStream
7
+ import java.io.IOException
8
+ import java.io.OutputStream
9
+ import java.security.MessageDigest
10
+ import java.util.Locale
11
+
12
+ internal data class CraftNativeImagePolicy(val expiresAt: Long)
13
+
14
+ internal fun craftNativeImagePolicy(
15
+ requestHeaders: Map<String, String>,
16
+ responseHeaders: Map<String, List<String>>,
17
+ now: Long,
18
+ ): CraftNativeImagePolicy? {
19
+ // Persist only the anonymous content the server explicitly calls public.
20
+ // A custom header can carry an account credential just like Authorization.
21
+ if (requestHeaders.isNotEmpty()) return null
22
+ fun values(name: String) = responseHeaders.entries
23
+ .filter { it.key.equals(name, ignoreCase = true) }.flatMap { it.value }
24
+ val variants = values("Vary").flatMap { it.split(',') }.map { it.trim().lowercase(Locale.ROOT) }
25
+ if (values("Set-Cookie").isNotEmpty() || variants.any { it in setOf("*", "cookie", "authorization") }) return null
26
+ val directives = values("Cache-Control").flatMap { it.split(',') }.map { it.trim().lowercase(Locale.ROOT) }
27
+ if ("public" !in directives || directives.any { it.substringBefore('=').trim() in setOf("private", "no-store", "no-cache") }) return null
28
+ val ages = directives.filter { it.substringBefore('=').trim() == "max-age" }
29
+ if (ages.isEmpty()) return null
30
+ val seconds = ages.map {
31
+ it.substringAfter('=', "").trim().removeSurrounding("\"").toLongOrNull()?.takeIf { age -> age > 0 } ?: return null
32
+ }.min().coerceAtMost(31_536_000L)
33
+ val ageHeader = values("Age").firstOrNull()
34
+ val age = if (ageHeader == null) 0L else ageHeader.trim().toLongOrNull()?.takeIf { it >= 0 } ?: return null
35
+ val remaining = seconds - age
36
+ if (remaining <= 0) return null
37
+ return runCatching { CraftNativeImagePolicy(Math.addExact(now, remaining * 1000)) }.getOrNull()
38
+ }
39
+
40
+ internal class CraftNativeImageCache(
41
+ private val root: File,
42
+ private val maxBytes: Long = 32L * 1024 * 1024,
43
+ private val maxEntryBytes: Long = 8L * 1024 * 1024,
44
+ private val maxEntries: Int = 128,
45
+ private val now: () -> Long = System::currentTimeMillis,
46
+ ) {
47
+ init {
48
+ require(maxBytes > 0 && maxEntryBytes > 0 && maxEntries > 0)
49
+ // A process killed while encoding can leave a staging file behind.
50
+ // All instances share this lock, so another writer cannot own it now.
51
+ synchronized(lock) {
52
+ runCatching {
53
+ root.listFiles().orEmpty().filter { it.name.endsWith(".tmp") }.forEach(File::delete)
54
+ prune()
55
+ }
56
+ }
57
+ }
58
+
59
+ private fun file(address: String): File {
60
+ val digest = MessageDigest.getInstance("SHA-256").digest(address.toByteArray(Charsets.UTF_8))
61
+ val name = digest.joinToString("") { (it.toInt() and 255).toString(16).padStart(2, '0') }
62
+ return File(root, "$name.cache")
63
+ }
64
+
65
+ fun read(address: String): ByteArray? = synchronized(lock) {
66
+ runCatching {
67
+ val target = file(address)
68
+ if (!target.isFile) return@runCatching null
69
+ if (target.length() <= HEADER_BYTES || target.length() > HEADER_BYTES + minOf(maxEntryBytes, maxBytes)) {
70
+ target.delete()
71
+ return@runCatching null
72
+ }
73
+ val data = DataInputStream(BufferedInputStream(target.inputStream())).use { input ->
74
+ if (input.readInt() != MAGIC || input.readLong() <= now()) null else input.readBytes()
75
+ }
76
+ if (data == null || data.isEmpty()) { target.delete(); null }
77
+ else { target.setLastModified(now()); data }
78
+ }.getOrNull()
79
+ }
80
+
81
+ fun write(address: String, policy: CraftNativeImagePolicy, encode: (OutputStream) -> Boolean): Boolean = synchronized(lock) {
82
+ var staging: File? = null
83
+ try {
84
+ if (policy.expiresAt <= now() || (!root.isDirectory && !root.mkdirs())) return@synchronized false
85
+ val target = file(address)
86
+ staging = File.createTempFile(target.name, ".tmp", root)
87
+ staging.outputStream().buffered().use { raw ->
88
+ val header = DataOutputStream(raw)
89
+ header.writeInt(MAGIC)
90
+ header.writeLong(policy.expiresAt)
91
+ val payload = BoundedImageOutput(raw, minOf(maxEntryBytes, maxBytes))
92
+ if (!encode(payload) || payload.written == 0L) throw IOException("Incomplete cached image")
93
+ payload.flush()
94
+ }
95
+ // Freshness and pixels are committed together; a failed write
96
+ // never replaces an earlier, complete image with a partial file.
97
+ if (!staging.renameTo(target)) return@synchronized false
98
+ target.setLastModified(now())
99
+ prune()
100
+ true
101
+ }
102
+ catch (error: Exception) { false }
103
+ finally { staging?.delete() }
104
+ }
105
+
106
+ fun remove(address: String) = synchronized(lock) { runCatching { file(address).delete() }; Unit }
107
+
108
+ private fun prune() {
109
+ val entries = root.listFiles().orEmpty().filter { entryName.matches(it.name) }.sortedBy(File::lastModified)
110
+ var count = entries.size
111
+ var bytes = entries.sumOf { (it.length() - HEADER_BYTES).coerceAtLeast(0) }
112
+ for (entry in entries) {
113
+ if (count <= maxEntries && bytes <= maxBytes) break
114
+ val size = (entry.length() - HEADER_BYTES).coerceAtLeast(0)
115
+ if (entry.delete()) { count--; bytes -= size }
116
+ }
117
+ }
118
+
119
+ private class BoundedImageOutput(private val output: OutputStream, private val limit: Long) : OutputStream() {
120
+ var written = 0L
121
+ private set
122
+ override fun write(value: Int) {
123
+ if (written >= limit) throw IOException("Cached image is too large")
124
+ output.write(value)
125
+ written++
126
+ }
127
+ override fun write(bytes: ByteArray, offset: Int, length: Int) {
128
+ if (written + length > limit) throw IOException("Cached image is too large")
129
+ output.write(bytes, offset, length)
130
+ written += length
131
+ }
132
+ override fun flush() = output.flush()
133
+ }
134
+
135
+ private companion object {
136
+ const val MAGIC = 0x43494d31
137
+ const val HEADER_BYTES = 12L
138
+ val entryName = Regex("^[a-f0-9]{64}\\.cache$")
139
+ val lock = Any()
140
+ }
141
+ }
@@ -122,6 +122,8 @@ import com.google.android.gms.maps.model.Polyline
122
122
  import com.google.android.gms.maps.model.PolylineOptions
123
123
  import org.json.JSONArray
124
124
  import org.json.JSONObject
125
+ import java.io.File
126
+ import java.net.HttpURLConnection
125
127
  import java.net.URL
126
128
  import java.text.SimpleDateFormat
127
129
  import java.util.Calendar
@@ -3805,6 +3807,7 @@ class MainActivity : AppCompatActivity() {
3805
3807
  private var windowSafeInsets = intArrayOf(0, 0, 0, 0)
3806
3808
  private var useMessagePorts = false
3807
3809
  private val imageExecutor = Executors.newFixedThreadPool(2)
3810
+ private val imageCache by lazy { CraftNativeImageCache(File(cacheDir, "craft-native-images")) }
3808
3811
  /** Lays out each isolate's prelude (the synchronous stores) off the main thread. */
3809
3812
  private val preludeExecutor = Executors.newSingleThreadExecutor()
3810
3813
  private val imageSources = mutableMapOf<ImageView, String>()
@@ -4156,13 +4159,22 @@ class MainActivity : AppCompatActivity() {
4156
4159
  * page on the app's site in the in-app browser. Another site's address
4157
4160
  * opens in the browser too.
4158
4161
  */
4159
- private fun navigateOpen(target: String, fromLink: Boolean = false) {
4162
+ private fun navigateOpen(target: String, fromLink: Boolean = false, replace: Boolean = false) {
4160
4163
  val path = routes.appPath(target, urlSchemes)
4161
4164
  if (path == null) {
4162
4165
  openExternal(target)
4163
4166
  return
4164
4167
  }
4165
4168
  val match = routes.match(path)
4169
+ if (replace && match != null && screens.isNotEmpty()) {
4170
+ val stack = screens
4171
+ val removed = stack.removeAt(stack.lastIndex)
4172
+ val previous = stack.lastOrNull()
4173
+ closeScreen(removed) {
4174
+ if (screens === stack && stack.lastOrNull() === previous) navigateOpen(path, fromLink)
4175
+ }
4176
+ return
4177
+ }
4166
4178
  val tab = routes.tabFor(path)
4167
4179
  if (tab != null) {
4168
4180
  selectTab(routes.tabs.indexOf(tab))
@@ -4770,6 +4782,12 @@ class MainActivity : AppCompatActivity() {
4770
4782
  // app script has no stack of its own; it uses NAVIGATE_OPEN.
4771
4783
  if (visibleScreen !== screen) return
4772
4784
  val route = payload.optString("screen").takeIf { it.isNotBlank() } ?: return
4785
+ // Resolve and replace in one host operation. A pop followed by
4786
+ // open would send the second message from a closing isolate.
4787
+ if (message.optString("type") == "NAVIGATE_REPLACE" && route.startsWith("/")) {
4788
+ navigateOpen(route, replace = true)
4789
+ return
4790
+ }
4773
4791
  if (message.optString("type") == "NAVIGATE_REPLACE") {
4774
4792
  val stack = screens
4775
4793
  val removed = stack.removeAt(stack.lastIndex)
@@ -6101,7 +6119,9 @@ class MainActivity : AppCompatActivity() {
6101
6119
  null
6102
6120
  } else defaultBackground
6103
6121
  val overflow = craftNativeOptionalString(style.opt("overflow"))
6104
- view.clipToOutline = overflow == "hidden"
6122
+ // A replaced image clips its own pixels to border-radius, including
6123
+ // when its surrounding layout permits visible overflow.
6124
+ view.clipToOutline = overflow == "hidden" || (view is ImageView && (radius > 0 || hasCornerRadii))
6105
6125
  if (view is ViewGroup) view.clipChildren = overflow == "hidden"
6106
6126
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
6107
6127
  (style.has("shadowColor") || style.has("shadowOpacity") || style.has("shadowRadius"))
@@ -6499,12 +6519,38 @@ class MainActivity : AppCompatActivity() {
6499
6519
  }
6500
6520
  imageJobs[view] = imageExecutor.submit {
6501
6521
  val bitmap = runCatching {
6502
- val connection = URL(uri).openConnection().apply {
6522
+ val publicRequest = headers.isEmpty() && Uri.parse(uri).userInfo.isNullOrEmpty()
6523
+ if (publicRequest) {
6524
+ val bytes = imageCache.read(uri)
6525
+ if (bytes != null) {
6526
+ val saved = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
6527
+ if (saved != null) return@runCatching saved
6528
+ imageCache.remove(uri)
6529
+ }
6530
+ }
6531
+ val connection = (URL(uri).openConnection() as HttpURLConnection).apply {
6503
6532
  connectTimeout = 10_000
6504
6533
  readTimeout = 10_000
6534
+ // This loader owns its bounded public cache. Do not let
6535
+ // an application's global ResponseCache retain requests.
6536
+ useCaches = false
6505
6537
  headers.forEach { (name, value) -> setRequestProperty(name, value) }
6506
6538
  }
6507
- connection.getInputStream().use(BitmapFactory::decodeStream)
6539
+ try {
6540
+ val downloaded = connection.inputStream.use(BitmapFactory::decodeStream)
6541
+ if (publicRequest) {
6542
+ val responseHeaders = connection.headerFields.entries.filter { it.key != null }
6543
+ .associate { it.key!! to it.value.orEmpty() }
6544
+ val policy = if (connection.responseCode == HttpURLConnection.HTTP_OK)
6545
+ craftNativeImagePolicy(headers, responseHeaders, System.currentTimeMillis()) else null
6546
+ if (downloaded != null && policy != null) {
6547
+ // Encode only after a complete decode, then commit
6548
+ // before onLoad so a cold offline reopen is safe.
6549
+ imageCache.write(uri, policy) { downloaded.compress(Bitmap.CompressFormat.PNG, 100, it) }
6550
+ } else imageCache.remove(uri)
6551
+ }
6552
+ downloaded
6553
+ } finally { connection.disconnect() }
6508
6554
  }.getOrNull()
6509
6555
  runOnUiThread {
6510
6556
  if (imageSources[view] != requestKey) return@runOnUiThread
package/dist/cli.js CHANGED
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3424
3424
  `);
3425
3425
  }
3426
3426
  // package.json
3427
- var version = "0.0.224";
3427
+ var version = "0.0.225";
3428
3428
 
3429
3429
  // bin/cli.ts
3430
3430
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -309,7 +309,7 @@ function craftBinaryNotFoundMessage(triedPath) {
309
309
  `);
310
310
  }
311
311
  // package.json
312
- var version = "0.0.224";
312
+ var version = "0.0.225";
313
313
 
314
314
  // src/sdk-version.ts
315
315
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -57,7 +57,7 @@ function craftBinaryNotFoundMessage(triedPath) {
57
57
  `);
58
58
  }
59
59
  // package.json
60
- var version = "0.0.224";
60
+ var version = "0.0.225";
61
61
 
62
62
  // src/sdk-version.ts
63
63
  var SDK_VERSION = version;
@@ -266,6 +266,8 @@ protocol CraftHybridScreenEvents: AnyObject {
266
266
  func nativeScreen(_ screen: CraftNativeScreenController, didFail message: String)
267
267
  /// `craft.navigation.open(path)`.
268
268
  func nativeScreen(_ screen: CraftNativeScreenController, open path: String)
269
+ /// `craft.navigation.replace(path)`: finish the current flow atomically.
270
+ func nativeScreen(_ screen: CraftNativeScreenController, replace path: String)
269
271
  }
270
272
 
271
273
  // MARK: - The shell
@@ -938,6 +940,10 @@ extension CraftHybrid: CraftHybridScreenEvents {
938
940
  func nativeScreen(_ screen: CraftNativeScreenController, open path: String) {
939
941
  open(path)
940
942
  }
943
+
944
+ func nativeScreen(_ screen: CraftNativeScreenController, replace path: String) {
945
+ controller?.showNative(path: path, replace: true)
946
+ }
941
947
  }
942
948
 
943
949
  /// The page's `craftHybrid` channel, only from a trusted origin's main frame.
@@ -1519,6 +1525,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1519
1525
  interruptTabSwipe(restoringSelection: true)
1520
1526
  let tabs = hybrid.tabIds
1521
1527
  if let tab = tabs.first(where: { CraftHybridRoutes.normalize($0) == match.path }) {
1528
+ // Finish the flow in its original tab before selecting another.
1529
+ if replace, let current = visibleStack, current.viewControllers.count > 1 {
1530
+ current.setViewControllers(Array(current.viewControllers.dropLast()), animated: false)
1531
+ }
1522
1532
  if tab != currentTab {
1523
1533
  selectTabAsIfTapped(tab)
1524
1534
  }
@@ -1541,7 +1551,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1541
1551
  return
1542
1552
  }
1543
1553
  let screen = entry(for: raw, isTabRoot: false, depth: nil)
1544
- if replace, nav.viewControllers.count > 1 {
1554
+ if replace, !nav.viewControllers.isEmpty {
1545
1555
  nav.setViewControllers(Array(nav.viewControllers.dropLast()) + [screen], animated: true)
1546
1556
  } else {
1547
1557
  nav.pushViewController(screen, animated: true)
@@ -1900,6 +1910,11 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1900
1910
  open(path)
1901
1911
  }
1902
1912
 
1913
+ func nativeScreen(_ screen: CraftNativeScreenController, replace path: String) {
1914
+ guard screen.navigationController?.topViewController === screen else { return }
1915
+ showNative(path: path, replace: true)
1916
+ }
1917
+
1903
1918
  private func log(_ message: String) {
1904
1919
  #if DEBUG
1905
1920
  NSLog("[craft hybrid] %@", message)
@@ -5416,6 +5416,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5416
5416
  guard !isAppScript, navigationController?.topViewController === self,
5417
5417
  let screen = payload["screen"] as? String, !screen.isEmpty,
5418
5418
  let navigation = navigationController else { return }
5419
+ if type == "NAVIGATE_REPLACE", screen.hasPrefix("/") {
5420
+ hybridEvents?.nativeScreen(self, replace: screen)
5421
+ return
5422
+ }
5419
5423
  let params = payload["params"] as? [String: Any] ?? [:]
5420
5424
  let next = CraftNativeScreenController(config: config, routeName: screen, routeParams: params)
5421
5425
  next.hybridEvents = hybridEvents
@@ -7006,6 +7010,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7006
7010
  view.clipsToBounds = overflow == "hidden"
7007
7011
  || (view is UIScrollView && overflow != "visible")
7008
7012
  || (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
7013
+ || (view is UIImageView && (borderRadius > 0 || hasCornerRadii))
7009
7014
  view.layer.masksToBounds = view.clipsToBounds
7010
7015
  view.craftLayoutStyle = CraftNativeLayoutStyle(style)
7011
7016
  (view as? CraftNativeFlowView)?.invalidateMeasurements()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.224",
3
+ "version": "0.0.225",
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>",