craft-native 0.0.224 → 0.0.226

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,142 @@
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
+ catch (error: OutOfMemoryError) { false }
104
+ finally { staging?.delete() }
105
+ }
106
+
107
+ fun remove(address: String) = synchronized(lock) { runCatching { file(address).delete() }; Unit }
108
+
109
+ private fun prune() {
110
+ val entries = root.listFiles().orEmpty().filter { entryName.matches(it.name) }.sortedBy(File::lastModified)
111
+ var count = entries.size
112
+ var bytes = entries.sumOf { (it.length() - HEADER_BYTES).coerceAtLeast(0) }
113
+ for (entry in entries) {
114
+ if (count <= maxEntries && bytes <= maxBytes) break
115
+ val size = (entry.length() - HEADER_BYTES).coerceAtLeast(0)
116
+ if (entry.delete()) { count--; bytes -= size }
117
+ }
118
+ }
119
+
120
+ private class BoundedImageOutput(private val output: OutputStream, private val limit: Long) : OutputStream() {
121
+ var written = 0L
122
+ private set
123
+ override fun write(value: Int) {
124
+ if (written >= limit) throw IOException("Cached image is too large")
125
+ output.write(value)
126
+ written++
127
+ }
128
+ override fun write(bytes: ByteArray, offset: Int, length: Int) {
129
+ if (written + length > limit) throw IOException("Cached image is too large")
130
+ output.write(bytes, offset, length)
131
+ written += length
132
+ }
133
+ override fun flush() = output.flush()
134
+ }
135
+
136
+ private companion object {
137
+ const val MAGIC = 0x43494d31
138
+ const val HEADER_BYTES = 12L
139
+ val entryName = Regex("^[a-f0-9]{64}\\.cache$")
140
+ val lock = Any()
141
+ }
142
+ }
@@ -120,6 +120,7 @@ internal class CraftNativeServices(
120
120
  done(success(CraftNativeMedia.prune(context, (0 until keep.length()).map { keep.optString(it) })))
121
121
  }
122
122
  "usage" -> done(success(CraftNativeMedia.usage(context)))
123
+ "clear" -> done(success(CraftNativeMedia.clear(context)))
123
124
  else -> done(failure("UNKNOWN_ACTION", "Unsupported Media method"))
124
125
  }
125
126
  "Background" -> {
@@ -1021,7 +1021,8 @@ internal object CraftNativeSymbols {
1021
1021
  * A native Video given an address with a copy here plays the copy.
1022
1022
  */
1023
1023
  internal object CraftNativeMedia {
1024
- private val inFlight = HashMap<String, MutableList<(CraftNativeCapabilityResult) -> Unit>>()
1024
+ private data class Flight(val id: String, val waiting: MutableList<(CraftNativeCapabilityResult) -> Unit>)
1025
+ private val inFlight = HashMap<String, Flight>()
1025
1026
  private val executor = Executors.newFixedThreadPool(2)
1026
1027
  private val main = Handler(Looper.getMainLooper())
1027
1028
 
@@ -1054,12 +1055,12 @@ internal object CraftNativeMedia {
1054
1055
  if (!cellular && metered(context)) {
1055
1056
  return done(CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("reason", "cellular")))
1056
1057
  }
1057
- synchronized(inFlight) {
1058
+ val flight = synchronized(inFlight) {
1058
1059
  inFlight[address]?.let { waiting ->
1059
- waiting.add(done)
1060
+ waiting.waiting.add(done)
1060
1061
  return
1061
1062
  }
1062
- inFlight[address] = mutableListOf(done)
1063
+ Flight(java.util.UUID.randomUUID().toString(), mutableListOf(done)).also { inFlight[address] = it }
1063
1064
  }
1064
1065
  executor.execute {
1065
1066
  val result = runCatching {
@@ -1073,7 +1074,7 @@ internal object CraftNativeMedia {
1073
1074
  val target = file(context, address)
1074
1075
  target.parentFile?.mkdirs()
1075
1076
  // Written aside and moved into place, so a half-written file is never played.
1076
- val staging = File(target.parentFile, "${target.name}.part")
1077
+ val staging = File(target.parentFile, "${target.name}.${flight.id}.part")
1077
1078
  try {
1078
1079
  val size = connection.inputStream.use { input -> staging.outputStream().use { input.copyTo(it) } }
1079
1080
  val expected = connection.contentLengthLong
@@ -1081,6 +1082,9 @@ internal object CraftNativeMedia {
1081
1082
  return@runCatching CraftNativeCapabilityResult(code = "NETWORK_ERROR", message = "media.keep received an incomplete file")
1082
1083
  }
1083
1084
  synchronized(inFlight) {
1085
+ if (inFlight[address]?.id != flight.id) {
1086
+ return@runCatching CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")
1087
+ }
1084
1088
  if (!staging.renameTo(target)) {
1085
1089
  return@runCatching CraftNativeCapabilityResult(code = "IO_ERROR", message = "media.keep could not store the file")
1086
1090
  }
@@ -1098,7 +1102,7 @@ internal object CraftNativeMedia {
1098
1102
  }
1099
1103
  CraftNativeCapabilityResult(code = if (full) "NO_SPACE" else if (error is java.net.SocketTimeoutException) "TIMEOUT" else "NETWORK_ERROR", message = error.message ?: "media.keep failed")
1100
1104
  }
1101
- val waiting = synchronized(inFlight) { inFlight.remove(address) ?: mutableListOf() }
1105
+ val waiting = synchronized(inFlight) { if (inFlight[address]?.id == flight.id) inFlight.remove(address)?.waiting.orEmpty() else emptyList() }
1102
1106
  main.post { waiting.forEach { it(result) } }
1103
1107
  }
1104
1108
  }
@@ -1115,12 +1119,31 @@ internal object CraftNativeMedia {
1115
1119
  var removed = 0
1116
1120
  synchronized(inFlight) {
1117
1121
  // A concurrent keep owns its target and staging file until it finishes.
1118
- val active = inFlight.keys.map { file(context, it).name }.toSet()
1122
+ val active = inFlight.flatMap { (address, flight) ->
1123
+ val name = file(context, address).name
1124
+ listOf(name, "$name.${flight.id}.part")
1125
+ }.toSet()
1126
+ root(context).listFiles()?.forEach { file ->
1127
+ if (file.name !in active && file.name !in wanted && file.delete()) removed++
1128
+ }
1129
+ }
1130
+ return removed
1131
+ }
1132
+
1133
+ /** Invalidates old transfers before deleting files; a later keep starts afresh. */
1134
+ fun clear(context: Context): Int {
1135
+ var removed = 0
1136
+ var failure: java.io.IOException? = null
1137
+ val waiting = synchronized(inFlight) {
1138
+ val callbacks = inFlight.values.flatMap { it.waiting }
1139
+ inFlight.clear()
1119
1140
  root(context).listFiles()?.forEach { file ->
1120
- val target = file.name.removeSuffix(".part")
1121
- if (target !in active && file.name !in wanted && file.delete()) removed++
1141
+ if (file.delete()) removed++ else failure = java.io.IOException("The video cache could not be cleared")
1122
1142
  }
1143
+ callbacks
1123
1144
  }
1145
+ main.post { waiting.forEach { it(CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")) } }
1146
+ failure?.let { throw it }
1124
1147
  return removed
1125
1148
  }
1126
1149
 
@@ -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>()
@@ -4088,6 +4091,7 @@ class MainActivity : AppCompatActivity() {
4088
4091
  show(target)
4089
4092
  } else {
4090
4093
  host.removeView(target.view)
4094
+ target.view.translationX = 0f
4091
4095
  current.view.translationX = 0f
4092
4096
  chrome?.selectTab(currentTab)
4093
4097
  tabSwipeTarget = null
@@ -4156,13 +4160,22 @@ class MainActivity : AppCompatActivity() {
4156
4160
  * page on the app's site in the in-app browser. Another site's address
4157
4161
  * opens in the browser too.
4158
4162
  */
4159
- private fun navigateOpen(target: String, fromLink: Boolean = false) {
4163
+ private fun navigateOpen(target: String, fromLink: Boolean = false, replace: Boolean = false) {
4160
4164
  val path = routes.appPath(target, urlSchemes)
4161
4165
  if (path == null) {
4162
4166
  openExternal(target)
4163
4167
  return
4164
4168
  }
4165
4169
  val match = routes.match(path)
4170
+ if (replace && match != null && screens.isNotEmpty()) {
4171
+ val stack = screens
4172
+ val removed = stack.removeAt(stack.lastIndex)
4173
+ val previous = stack.lastOrNull()
4174
+ closeScreen(removed) {
4175
+ if (screens === stack && stack.lastOrNull() === previous) navigateOpen(path, fromLink)
4176
+ }
4177
+ return
4178
+ }
4166
4179
  val tab = routes.tabFor(path)
4167
4180
  if (tab != null) {
4168
4181
  selectTab(routes.tabs.indexOf(tab))
@@ -4492,6 +4505,11 @@ class MainActivity : AppCompatActivity() {
4492
4505
  * screen shown before hears true (its `navigation.onAppear`).
4493
4506
  */
4494
4507
  private fun show(screen: Screen) {
4508
+ // A retained tab may have been the offscreen preview of a cancelled
4509
+ // swipe, or the outgoing view of a completed swipe. Its animator can
4510
+ // finish after the peer animator, so clear that ownership on display.
4511
+ screen.view.animate().withEndAction(null).setListener(null).cancel()
4512
+ screen.view.translationX = 0f
4495
4513
  // A navigation breadcrumb: the trail a crash report carries.
4496
4514
  if (visibleScreen !== screen) CraftCrashReporter.screenAppeared(screen.route ?: "home")
4497
4515
  val bars = chrome
@@ -4770,6 +4788,12 @@ class MainActivity : AppCompatActivity() {
4770
4788
  // app script has no stack of its own; it uses NAVIGATE_OPEN.
4771
4789
  if (visibleScreen !== screen) return
4772
4790
  val route = payload.optString("screen").takeIf { it.isNotBlank() } ?: return
4791
+ // Resolve and replace in one host operation. A pop followed by
4792
+ // open would send the second message from a closing isolate.
4793
+ if (message.optString("type") == "NAVIGATE_REPLACE" && route.startsWith("/")) {
4794
+ navigateOpen(route, replace = true)
4795
+ return
4796
+ }
4773
4797
  if (message.optString("type") == "NAVIGATE_REPLACE") {
4774
4798
  val stack = screens
4775
4799
  val removed = stack.removeAt(stack.lastIndex)
@@ -6101,7 +6125,9 @@ class MainActivity : AppCompatActivity() {
6101
6125
  null
6102
6126
  } else defaultBackground
6103
6127
  val overflow = craftNativeOptionalString(style.opt("overflow"))
6104
- view.clipToOutline = overflow == "hidden"
6128
+ // A replaced image clips its own pixels to border-radius, including
6129
+ // when its surrounding layout permits visible overflow.
6130
+ view.clipToOutline = overflow == "hidden" || (view is ImageView && (radius > 0 || hasCornerRadii))
6105
6131
  if (view is ViewGroup) view.clipChildren = overflow == "hidden"
6106
6132
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
6107
6133
  (style.has("shadowColor") || style.has("shadowOpacity") || style.has("shadowRadius"))
@@ -6499,12 +6525,38 @@ class MainActivity : AppCompatActivity() {
6499
6525
  }
6500
6526
  imageJobs[view] = imageExecutor.submit {
6501
6527
  val bitmap = runCatching {
6502
- val connection = URL(uri).openConnection().apply {
6528
+ val publicRequest = headers.isEmpty() && Uri.parse(uri).userInfo.isNullOrEmpty()
6529
+ if (publicRequest) {
6530
+ val bytes = imageCache.read(uri)
6531
+ if (bytes != null) {
6532
+ val saved = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
6533
+ if (saved != null) return@runCatching saved
6534
+ imageCache.remove(uri)
6535
+ }
6536
+ }
6537
+ val connection = (URL(uri).openConnection() as HttpURLConnection).apply {
6503
6538
  connectTimeout = 10_000
6504
6539
  readTimeout = 10_000
6540
+ // This loader owns its bounded public cache. Do not let
6541
+ // an application's global ResponseCache retain requests.
6542
+ useCaches = false
6505
6543
  headers.forEach { (name, value) -> setRequestProperty(name, value) }
6506
6544
  }
6507
- connection.getInputStream().use(BitmapFactory::decodeStream)
6545
+ try {
6546
+ val downloaded = connection.inputStream.use(BitmapFactory::decodeStream)
6547
+ if (publicRequest) {
6548
+ val responseHeaders = connection.headerFields.entries.filter { it.key != null }
6549
+ .associate { it.key!! to it.value.orEmpty() }
6550
+ val policy = if (connection.responseCode == HttpURLConnection.HTTP_OK)
6551
+ craftNativeImagePolicy(headers, responseHeaders, System.currentTimeMillis()) else null
6552
+ if (downloaded != null && policy != null) {
6553
+ // Encode only after a complete decode, then commit
6554
+ // before onLoad so a cold offline reopen is safe.
6555
+ imageCache.write(uri, policy) { downloaded.compress(Bitmap.CompressFormat.PNG, 100, it) }
6556
+ } else imageCache.remove(uri)
6557
+ }
6558
+ downloaded
6559
+ } finally { connection.disconnect() }
6508
6560
  }.getOrNull()
6509
6561
  runOnUiThread {
6510
6562
  if (imageSources[view] != requestKey) return@runOnUiThread
package/dist/browser.js CHANGED
@@ -283,6 +283,13 @@ async function createBrowserPlatform(options = {}) {
283
283
  };
284
284
  const lifetime = new AbortController;
285
285
  let account = new AbortController;
286
+ let mediaTransfers = new AbortController;
287
+ let mediaWrites = Promise.resolve();
288
+ const writeMedia = (work) => {
289
+ const write = mediaWrites.then(work);
290
+ mediaWrites = write.catch(() => {});
291
+ return write;
292
+ };
286
293
  let mediaScope = persistence.scope();
287
294
  let wakeLock = null;
288
295
  let disposed = false;
@@ -365,6 +372,7 @@ async function createBrowserPlatform(options = {}) {
365
372
  if (!("caches" in window))
366
373
  return url;
367
374
  const scope = persistence.scope();
375
+ const transfer = mediaTransfers.signal;
368
376
  const existing = mediaURLs.get(url);
369
377
  if (existing)
370
378
  return existing;
@@ -372,7 +380,7 @@ async function createBrowserPlatform(options = {}) {
372
380
  if (!saved)
373
381
  return url;
374
382
  const local = URL.createObjectURL(await saved.blob());
375
- if (disposed || scope !== persistence.scope()) {
383
+ if (disposed || transfer.aborted || scope !== persistence.scope()) {
376
384
  URL.revokeObjectURL(local);
377
385
  return url;
378
386
  }
@@ -629,6 +637,25 @@ async function createBrowserPlatform(options = {}) {
629
637
  if (module === "Media") {
630
638
  if (!("caches" in window))
631
639
  return unsupported(module, method);
640
+ if (method === "clear") {
641
+ mediaTransfers.abort();
642
+ mediaTransfers = new AbortController;
643
+ const name = cacheName();
644
+ return writeMedia(async () => {
645
+ const cache2 = await caches.open(name);
646
+ let removed = 0;
647
+ for (const entry of await cache2.keys()) {
648
+ if (await cache2.delete(entry))
649
+ removed++;
650
+ const local = mediaURLs.get(entry.url);
651
+ if (local)
652
+ URL.revokeObjectURL(local);
653
+ mediaURLs.delete(entry.url);
654
+ }
655
+ return removed;
656
+ });
657
+ }
658
+ signal = AbortSignal.any([signal, mediaTransfers.signal]);
632
659
  const cache = await caches.open(cacheName());
633
660
  if (method === "keep") {
634
661
  const url = String(args[0]);
@@ -646,7 +673,11 @@ async function createBrowserPlatform(options = {}) {
646
673
  signal.throwIfAborted();
647
674
  const headers = new Headers(response.headers);
648
675
  headers.set("x-craft-size", String(body.size));
649
- await cache.put(url, new Response(body, { headers, status: response.status }));
676
+ await writeMedia(async () => {
677
+ signal.throwIfAborted();
678
+ await cache.put(url, new Response(body, { headers, status: response.status }));
679
+ signal.throwIfAborted();
680
+ });
650
681
  return { ok: true, url, path: url, size: body.size };
651
682
  }
652
683
  if (method === "path")
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.226";
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.226";
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.226";
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)
@@ -254,6 +254,12 @@ enum CraftNativeActions {
254
254
  return
255
255
  case ("Media", "usage"):
256
256
  sync = .success(CraftNativeMedia.usage())
257
+ case ("Media", "clear"):
258
+ DispatchQueue.global(qos: .utility).async {
259
+ do { complete(requestToken, .success(try CraftNativeMedia.clear()), completion) }
260
+ catch { complete(requestToken, failure("IO_ERROR", "The video cache could not be cleared"), completion) }
261
+ }
262
+ return
257
263
  case ("Lifecycle", "getState"):
258
264
  sync = .success(currentAppState())
259
265
  case ("DeepLinks", "getInitialURL"):
@@ -782,7 +788,7 @@ enum CraftNativeActions {
782
788
  request.allowsExpensiveNetworkAccess = false
783
789
  request.allowsConstrainedNetworkAccess = false
784
790
  }
785
- if CraftNativeMedia.join(address, { complete(requestToken, $0, completion) }) { return }
791
+ guard let flight = CraftNativeMedia.begin(address, { complete(requestToken, $0, completion) }) else { return }
786
792
  let task = fetchSession.downloadTask(with: request) { location, response, error in
787
793
  cancellationLock.lock()
788
794
  fetchTasks.removeValue(forKey: requestToken)
@@ -790,25 +796,25 @@ enum CraftNativeActions {
790
796
  if let error = error {
791
797
  // Refused only because the connection is metered: not a failure, a wait.
792
798
  if let reason = (error as? URLError)?.networkUnavailableReason, reason == .expensive || reason == .constrained {
793
- CraftNativeMedia.finish(address, .success(["ok": false, "url": address, "reason": "cellular"]))
799
+ CraftNativeMedia.finish(address, flight: flight, .success(["ok": false, "url": address, "reason": "cellular"]))
794
800
  return
795
801
  }
796
802
  let code = (error as? URLError)?.code == .timedOut ? "TIMEOUT" : "NETWORK_ERROR"
797
- CraftNativeMedia.finish(address, failure(CraftNativeMedia.errorCode(error, fallback: code), error.localizedDescription))
803
+ CraftNativeMedia.finish(address, flight: flight, failure(CraftNativeMedia.errorCode(error, fallback: code), error.localizedDescription))
798
804
  return
799
805
  }
800
806
  guard let http = response as? HTTPURLResponse, let location else {
801
- CraftNativeMedia.finish(address, failure("NETWORK_ERROR", "media.keep received a response that is not HTTP"))
807
+ CraftNativeMedia.finish(address, flight: flight, failure("NETWORK_ERROR", "media.keep received a response that is not HTTP"))
802
808
  return
803
809
  }
804
810
  // The system deletes `location` once this returns: move it now.
805
- CraftNativeMedia.finish(address, CraftNativeMedia.store(address, at: location, response: http))
811
+ CraftNativeMedia.finish(address, flight: flight, CraftNativeMedia.store(address, at: location, response: http, flight: flight))
806
812
  }
807
813
  cancellationLock.lock()
808
814
  let alreadyCancelled = cancelledRequests.contains(requestToken)
809
815
  if !alreadyCancelled { fetchTasks[requestToken] = task }
810
816
  cancellationLock.unlock()
811
- if alreadyCancelled { CraftNativeMedia.finish(address, failure("CANCELLED", "media.keep was cancelled")) }
817
+ if alreadyCancelled { CraftNativeMedia.finish(address, flight: flight, failure("CANCELLED", "media.keep was cancelled")) }
812
818
  else { task.resume() }
813
819
  }
814
820
 
@@ -514,7 +514,11 @@ enum CraftNativeMedia {
514
514
 
515
515
  private static let lock = NSLock()
516
516
  /// Addresses being fetched now, so a second `keep` waits for the first.
517
- private static var inFlight: [String: [(Result<Any, CraftNativeActionError>) -> Void]] = [:]
517
+ private struct Flight {
518
+ let id: UUID
519
+ var waiting: [(Result<Any, CraftNativeActionError>) -> Void]
520
+ }
521
+ private static var inFlight: [String: Flight] = [:]
518
522
 
519
523
  /// Where `address`'s copy lives (whether or not it is there yet). The
520
524
  /// extension is the address's, because AVPlayer reads the type from it.
@@ -537,28 +541,29 @@ enum CraftNativeMedia {
537
541
  return ["ok": true, "url": address, "path": url.path, "size": size]
538
542
  }
539
543
 
540
- /// Joins a fetch of `address` already under way; true when one was.
541
- static func join(_ address: String, _ done: @escaping (Result<Any, CraftNativeActionError>) -> Void) -> Bool {
544
+ /// A new download's identity, or nil after joining an existing download.
545
+ static func begin(_ address: String, _ done: @escaping (Result<Any, CraftNativeActionError>) -> Void) -> UUID? {
542
546
  lock.lock()
543
547
  defer { lock.unlock() }
544
548
  if inFlight[address] != nil {
545
- inFlight[address]!.append(done)
546
- return true
549
+ inFlight[address]!.waiting.append(done)
550
+ return nil
547
551
  }
548
- inFlight[address] = [done]
549
- return false
552
+ let id = UUID()
553
+ inFlight[address] = Flight(id: id, waiting: [done])
554
+ return id
550
555
  }
551
556
 
552
- static func finish(_ address: String, _ result: Result<Any, CraftNativeActionError>) {
557
+ static func finish(_ address: String, flight: UUID, _ result: Result<Any, CraftNativeActionError>) {
553
558
  lock.lock()
554
- let waiting = inFlight.removeValue(forKey: address) ?? []
559
+ let waiting = inFlight[address]?.id == flight ? inFlight.removeValue(forKey: address)?.waiting ?? [] : []
555
560
  lock.unlock()
556
561
  for done in waiting { done(result) }
557
562
  }
558
563
 
559
564
  /// A finished download moved into place (atomically, so a half-written
560
565
  /// file is never played), or a refusal with the server's status.
561
- static func store(_ address: String, at location: URL, response: HTTPURLResponse) -> Result<Any, CraftNativeActionError> {
566
+ static func store(_ address: String, at location: URL, response: HTTPURLResponse, flight: UUID? = nil) -> Result<Any, CraftNativeActionError> {
562
567
  guard response.statusCode == 200 else {
563
568
  return .success(["ok": false, "url": address, "status": response.statusCode])
564
569
  }
@@ -566,6 +571,9 @@ enum CraftNativeMedia {
566
571
  // Commit and prune share a lock; neither can remove a file being committed.
567
572
  lock.lock()
568
573
  defer { lock.unlock() }
574
+ if let flight, inFlight[address]?.id != flight {
575
+ return .failure(CraftNativeActionError(code: "CANCELLED", message: "The video cache was cleared"))
576
+ }
569
577
  let staging = root.appendingPathComponent(UUID().uuidString + ".part")
570
578
  defer { try? fm.removeItem(at: staging) }
571
579
  do {
@@ -596,6 +604,25 @@ enum CraftNativeMedia {
596
604
  }
597
605
  }
598
606
 
607
+ /// End an account's cache lifetime.
608
+ static func clear() throws -> Int {
609
+ lock.lock()
610
+ let waiting = inFlight.values.flatMap { $0.waiting }
611
+ inFlight.removeAll()
612
+ let fm = FileManager.default
613
+ let files = (try? fm.contentsOfDirectory(at: root, includingPropertiesForKeys: nil)) ?? []
614
+ var removed = 0
615
+ var failure: Error?
616
+ for url in files {
617
+ do { try fm.removeItem(at: url); removed += 1 }
618
+ catch { failure = error }
619
+ }
620
+ lock.unlock()
621
+ for done in waiting { done(.failure(CraftNativeActionError(code: "CANCELLED", message: "The video cache was cleared"))) }
622
+ if let failure { throw failure }
623
+ return removed
624
+ }
625
+
599
626
  /// Removes every copy whose address is not in `keep`; answers how many.
600
627
  static func prune(keep: [String]) -> Int {
601
628
  lock.lock()
@@ -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
@@ -6067,7 +6071,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6067
6071
  if let current = image.image { image.image = imageForDisplay(current, view: image) }
6068
6072
  let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
6069
6073
  image.contentMode = imageContentMode(requestedResizeMode)
6070
- image.clipsToBounds = image.contentMode == .scaleAspectFill
6074
+ image.clipsToBounds = image.clipsToBounds || image.contentMode == .scaleAspectFill
6071
6075
  updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: image)
6072
6076
  updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: image)
6073
6077
  updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: image)
@@ -6086,7 +6090,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6086
6090
  if let current = background.image { background.image = imageForDisplay(current, view: background) }
6087
6091
  let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
6088
6092
  background.contentMode = imageContentMode(requestedResizeMode)
6089
- background.clipsToBounds = background.contentMode == .scaleAspectFill
6093
+ background.clipsToBounds = background.clipsToBounds || background.contentMode == .scaleAspectFill
6090
6094
  updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: background)
6091
6095
  updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: background)
6092
6096
  updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: background)
@@ -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()
@@ -149,6 +149,8 @@ ${await Bun.file(join(fixtures, "media-cache.swift.fixture")).text()}`);
149
149
  print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, "recover"]));
150
150
  if (counts.get("/ios/slow.mp4") !== 1)
151
151
  throw new Error("iOS duplicate fetch: keep did not coalesce");
152
+ if (counts.get("/ios/account/slow.mp4") !== 2)
153
+ throw new Error("iOS account switch did not start a fresh transfer");
152
154
  print("PASS: iOS simulator interruption, restart, retry, prune and cached video decode");
153
155
  }
154
156
  }
@@ -232,6 +234,8 @@ ${await Bun.file(join(fixtures, "media-cache.kt")).text()}`);
232
234
  await probe("recover");
233
235
  if (counts.get("/android/slow.mp4") !== 1)
234
236
  throw new Error("Android duplicate fetch: keep did not coalesce");
237
+ if (counts.get("/android/account/slow.mp4") !== 2)
238
+ throw new Error("Android account switch did not start a fresh transfer");
235
239
  print("PASS: Android emulator interruption, restart, retry, prune and cached video decode");
236
240
  }
237
241
  }
@@ -75,10 +75,11 @@ class DownloadProbe : android.app.Instrumentation() {
75
75
  "interrupt" -> {
76
76
  CraftNativeMedia.prune(targetContext, emptyList())
77
77
  CraftNativeMedia.keep(targetContext, restart, JSONObject(), true) {}
78
- val part = File(CraftNativeMedia.file(targetContext, restart).absolutePath + ".part")
78
+ val target = CraftNativeMedia.file(targetContext, restart)
79
+ fun partial() = target.parentFile!!.listFiles()?.any { it.name.startsWith(target.name + ".") && it.name.endsWith(".part") && it.length() > 0 } == true
79
80
  val until = System.currentTimeMillis() + 25000
80
- while ((!part.exists() || part.length() == 0L) && System.currentTimeMillis() < until) Thread.sleep(25)
81
- check(part.length() > 0, "download is in progress before process death")
81
+ while (!partial() && System.currentTimeMillis() < until) Thread.sleep(25)
82
+ check(partial(), "download is in progress before process death")
82
83
  android.os.Process.killProcess(android.os.Process.myPid())
83
84
  }
84
85
  "recover" -> {
@@ -111,6 +112,26 @@ class DownloadProbe : android.app.Instrumentation() {
111
112
  check(answers.size == 2 && answers.all { it }, "concurrent downloads coalesce and survive prune")
112
113
  check(root.listFiles()?.none { it.name.endsWith(".part") } != false, "no abandoned staging files remain")
113
114
  CraftNativeMedia.prune(targetContext, emptyList())
115
+ val privateVideo = "$base/account/slow.mp4"
116
+ val previous = java.util.concurrent.CountDownLatch(1)
117
+ var cancelled = false
118
+ CraftNativeMedia.keep(targetContext, privateVideo, JSONObject(), true) { cancelled = it.code == "CANCELLED"; previous.countDown() }
119
+ val privateTarget = CraftNativeMedia.file(targetContext, privateVideo)
120
+ fun downloading() = root.listFiles()?.any { it.name.startsWith(privateTarget.name + ".") && it.name.endsWith(".part") && it.length() > 0 } == true
121
+ val deadline = System.currentTimeMillis() + 25000
122
+ while (!downloading() && System.currentTimeMillis() < deadline) Thread.sleep(10)
123
+ check(downloading(), "the previous athlete's download is in progress before sign-out")
124
+ CraftNativeMedia.clear(targetContext)
125
+ check(previous.await(5, java.util.concurrent.TimeUnit.SECONDS) && cancelled, "sign-out cancels the previous athlete's download")
126
+ check(CraftNativeMedia.local(targetContext, privateVideo) == null, "signed-out video is not playable from cache")
127
+ check(keep(privateVideo), "the next athlete downloads the same URL in a new transfer")
128
+ val media = android.media.MediaMetadataRetriever()
129
+ try {
130
+ media.setDataSource(CraftNativeMedia.local(targetContext, privateVideo)!!.absolutePath)
131
+ check(media.getFrameAtTime(1000000) != null, "the next athlete's local video decodes after the old download settles")
132
+ } finally { media.release() }
133
+ CraftNativeMedia.clear(targetContext)
134
+ check(CraftNativeMedia.local(targetContext, privateVideo) == null, "sign-out removes the next athlete's cached copy too")
114
135
  }
115
136
  }
116
137
  result.putString("stream", "${proof}PASS: ${args.getString("phase")} native download probe")
@@ -17,16 +17,16 @@ var latestTask: URLSessionTask?
17
17
  var latestErrorCode: String?
18
18
  func start(_ address: String, _ done: @escaping (Bool) -> Void) {
19
19
  if let local = CraftNativeMedia.local(address) { done((CraftNativeMedia.describe(address, local)["size"] as? Int ?? 0) > 0); return }
20
- if CraftNativeMedia.join(address, { result in
20
+ guard let flight = CraftNativeMedia.begin(address, { result in
21
21
  if case .success(let data) = result { done((data as? [String: Any])?["ok"] as? Bool == true) }
22
22
  else if case .failure(let error) = result { latestErrorCode = error.code; done(false) }
23
- }) { return }
23
+ }) else { return }
24
24
  let task = URLSession.shared.downloadTask(with: URL(string: address)!) { location, response, error in
25
25
  let result: Result<Any, CraftNativeActionError>
26
26
  if error != nil { result = .failure(CraftNativeActionError(code: "NETWORK_ERROR", message: "Interrupted")) }
27
- else if let location, let response = response as? HTTPURLResponse { result = CraftNativeMedia.store(address, at: location, response: response) }
27
+ else if let location, let response = response as? HTTPURLResponse { result = CraftNativeMedia.store(address, at: location, response: response, flight: flight) }
28
28
  else { result = .failure(CraftNativeActionError(code: "NETWORK_ERROR", message: "No file")) }
29
- CraftNativeMedia.finish(address, result)
29
+ CraftNativeMedia.finish(address, flight: flight, result)
30
30
  }
31
31
  latestTask = task
32
32
  task.resume()
@@ -116,3 +116,19 @@ check(answers == [true, true], "concurrent downloads coalesce and survive prune"
116
116
  let parts = (try? fm.contentsOfDirectory(atPath: CraftNativeMedia.root.path))?.filter { $0.hasSuffix(".part") } ?? []
117
117
  check(parts.isEmpty, "no abandoned staging files remain")
118
118
  _ = CraftNativeMedia.prune(keep: [])
119
+ let privateVideo = base + "/account/slow.mp4"
120
+ var previousAnswer: Bool?
121
+ start(privateVideo) { previousAnswer = $0 }
122
+ let previousTask = latestTask!
123
+ wait { previousTask.countOfBytesReceived > 0 }
124
+ _ = try CraftNativeMedia.clear()
125
+ check(previousAnswer == false && latestErrorCode == "CANCELLED", "sign-out cancels the previous athlete's download")
126
+ check(CraftNativeMedia.local(privateVideo) == nil, "signed-out video is not playable from cache")
127
+ check(keep(privateVideo), "the next athlete downloads the same URL in a new transfer")
128
+ wait { previousTask.state == .completed }
129
+ let cached = CraftNativeMedia.local(privateVideo)!
130
+ let generator = AVAssetImageGenerator(asset: AVURLAsset(url: cached))
131
+ _ = try generator.copyCGImage(at: CMTime(seconds: 1, preferredTimescale: 600), actualTime: nil)
132
+ check(true, "the next athlete's local video decodes after the old download settles")
133
+ _ = try CraftNativeMedia.clear()
134
+ check(CraftNativeMedia.local(privateVideo) == nil, "sign-out removes the next athlete's cached copy too")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.224",
3
+ "version": "0.0.226",
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>",