craft-native 0.0.491 → 0.0.492

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.
@@ -1,2 +1,9 @@
1
1
  /** Uses the Android builder's Kotlin version, including its transitive compiler dependencies. */
2
2
  export declare function runKotlinFixture(source: string, libraries?: readonly string[]): Promise<void>;
3
+ /**
4
+ * One member of a Kotlin class in a template, as shipped, so a fixture can run
5
+ * the real text rather than a copy of it: a function, or a property with its
6
+ * initialiser. Members sit at four spaces; the member is everything deeper,
7
+ * to its closing brace when it has a block body.
8
+ */
9
+ export declare function liftKotlinMember(template: string, name: string): string;
@@ -34,10 +34,12 @@ import org.json.JSONTokener
34
34
  import java.io.ByteArrayOutputStream
35
35
  import java.io.File
36
36
  import java.io.FileOutputStream
37
+ import java.io.IOException
37
38
  import java.net.HttpURLConnection
38
39
  import java.net.SocketTimeoutException
39
40
  import java.net.URL
40
41
  import java.net.URLConnection
42
+ import java.net.URLDecoder
41
43
  import java.util.UUID
42
44
  import java.util.concurrent.ConcurrentHashMap
43
45
  import java.util.concurrent.Executors
@@ -540,8 +542,7 @@ internal class CraftNativeCapabilities(
540
542
  val options = args.optJSONObject(2) ?: JSONObject()
541
543
  val bytes = when (options.optString("encoding", "utf8")) {
542
544
  "utf8" -> data.toByteArray(Charsets.UTF_8)
543
- "base64" -> runCatching { Base64.decode(data, Base64.DEFAULT) }
544
- .getOrElse { return failure("INVALID_ARGUMENT", "files.write base64 is invalid") }
545
+ "base64" -> decodeBase64(data) ?: return failure("INVALID_ARGUMENT", "files.write base64 is invalid")
545
546
  else -> return failure("INVALID_ARGUMENT", "files.write encoding is 'utf8' or 'base64'")
546
547
  }
547
548
  store(bytes, name, options.optString("type").ifBlank { URLConnection.guessContentTypeFromName(name) ?: "application/octet-stream" })
@@ -555,6 +556,30 @@ internal class CraftNativeCapabilities(
555
556
  }
556
557
  }
557
558
 
559
+ /**
560
+ * Base64 as `atob` reads it, which is what the browser host runs and what
561
+ * UIKit is held to: the alphabet, ASCII white space anywhere, padding
562
+ * optional but right when it is there, and nothing else. Android's own
563
+ * decoder skips any character it does not know, so the `-` and `_` of a
564
+ * URL-safe encoding would vanish and a wrong file be written with no
565
+ * error. It is handed only what has already been read as valid.
566
+ */
567
+ private fun decodeBase64(text: String): ByteArray? {
568
+ val data = StringBuilder(text.length)
569
+ var padding = 0
570
+ for (letter in text) {
571
+ when {
572
+ letter == ' ' || letter == '\t' || letter == '\n' || letter == '\u000C' || letter == '\r' -> Unit
573
+ letter == '=' -> padding += 1
574
+ padding > 0 -> return null
575
+ letter in 'A'..'Z' || letter in 'a'..'z' || letter in '0'..'9' || letter == '+' || letter == '/' -> data.append(letter)
576
+ else -> return null
577
+ }
578
+ }
579
+ if (padding > 2 || data.length % 4 == 1 || (padding > 0 && (data.length + padding) % 4 != 0)) return null
580
+ return runCatching { Base64.decode(data.toString(), Base64.DEFAULT) }.getOrNull()
581
+ }
582
+
558
583
  private fun download(requestToken: String, args: JSONArray): CraftNativeCapabilityResult {
559
584
  val address = args.optString(0).trim()
560
585
  val url = validURL(address) ?: return failure("INVALID_ARGUMENT", "files.download needs an absolute http or https URL")
@@ -585,18 +610,47 @@ internal class CraftNativeCapabilities(
585
610
  return try {
586
611
  val status = connection.responseCode
587
612
  if (status !in 200..299) return refusedDownload(connection, status, requestToken)
588
- val name = options.optString("name").ifBlank { connection.url.path.substringAfterLast('/').ifBlank { "download" } }
613
+ // The name the screen asked for, else the server's, else the
614
+ // address's: the order UIKit's download keeps a file under.
615
+ val name = options.optString("name").ifBlank { downloadName(connection.getHeaderField("Content-Disposition"), connection.url.path) }
589
616
  val file = File(fileRoot, UUID.randomUUID().toString()).apply { mkdirs() }.resolve(safeFileName(name))
590
- connection.inputStream.use { input -> FileOutputStream(file).use { output ->
591
- val buffer = ByteArray(8192)
592
- while (true) {
593
- if (cancelled.contains(requestToken)) return failure("CANCELLED", "File download was cancelled")
594
- val count = input.read(buffer)
595
- if (count < 0) break
596
- output.write(buffer, 0, count)
617
+ var kept = false
618
+ try {
619
+ val output = try { FileOutputStream(file) } catch (error: IOException) {
620
+ return failure("WRITE_ERROR", error.message ?: "File could not be written")
597
621
  }
598
- } }
599
- success(descriptor(file, connection.contentType ?: "application/octet-stream"))
622
+ var written = 0L
623
+ output.use { connection.inputStream.use { input ->
624
+ val buffer = ByteArray(8192)
625
+ while (true) {
626
+ if (cancelled.contains(requestToken)) return failure("CANCELLED", "File download was cancelled")
627
+ val count = input.read(buffer)
628
+ if (count < 0) break
629
+ // A full disk is not the network's failure.
630
+ try { output.write(buffer, 0, count) } catch (error: IOException) {
631
+ return failure("WRITE_ERROR", error.message ?: "File could not be written")
632
+ }
633
+ written += count
634
+ }
635
+ } }
636
+ // A body shorter than the server said it would be is not the
637
+ // file. Android's connection fails such a read itself, and
638
+ // other implementations simply stop, so the length is checked
639
+ // here and the answer is this function's own. (When the
640
+ // connection decompressed the body it reports no length.)
641
+ val declared = connection.getHeaderField("Content-Length")?.trim()?.toLongOrNull()
642
+ if (declared != null && written != declared) {
643
+ // A cancel can end the read early rather than fail it.
644
+ return if (cancelled.contains(requestToken)) failure("CANCELLED", "File download was cancelled")
645
+ else failure("NETWORK_ERROR", "File download ended after $written of $declared bytes")
646
+ }
647
+ kept = true
648
+ // `ok` is how a screen tells this from a refusal, which
649
+ // resolves `{ ok: false, status, body }` rather than rejecting.
650
+ success(descriptor(file, connection.contentType ?: "application/octet-stream").put("ok", true).put("status", status))
651
+ } finally {
652
+ if (!kept) removePartial(file)
653
+ }
600
654
  } catch (error: SocketTimeoutException) {
601
655
  failure("TIMEOUT", error.message ?: "File download timed out")
602
656
  } catch (error: Exception) {
@@ -608,6 +662,38 @@ internal class CraftNativeCapabilities(
608
662
  }
609
663
  }
610
664
 
665
+ /**
666
+ * A download that stopped part-way is not a file. Nothing was told its
667
+ * path, so nothing would remove it before the daily sweep.
668
+ */
669
+ private fun removePartial(file: File) {
670
+ runCatching { file.delete() }
671
+ // Its folder was made for it alone; delete refuses one that is not empty.
672
+ runCatching { file.parentFile?.takeIf { it != fileRoot }?.delete() }
673
+ }
674
+
675
+ /**
676
+ * The server's name for a download (`Content-Disposition`, the encoded
677
+ * `filename*` before the plain one), else the address's last segment as a
678
+ * person reads it rather than as it is percent-encoded.
679
+ */
680
+ private fun downloadName(disposition: String?, path: String): String {
681
+ val header = disposition.orEmpty()
682
+ Regex("filename\\*\\s*=\\s*UTF-8''([^;\\s]+)", RegexOption.IGNORE_CASE).find(header)?.let { match ->
683
+ percentDecoded(match.groupValues[1])?.takeIf { it.isNotBlank() }?.let { return it }
684
+ }
685
+ Regex("filename\\s*=\\s*(?:\"((?:[^\"\\\\]|\\\\.)*)\"|([^;]+))", RegexOption.IGNORE_CASE).find(header)?.let { match ->
686
+ val quoted = match.groups[1]?.value?.replace(Regex("\\\\(.)")) { it.groupValues[1] }
687
+ (quoted ?: match.groupValues[2]).trim().takeIf { it.isNotBlank() }?.let { return it }
688
+ }
689
+ val segment = path.substringAfterLast('/')
690
+ return (percentDecoded(segment) ?: segment).ifBlank { "download" }
691
+ }
692
+
693
+ /** Percent-decoded as UTF-8, or null when it is not validly encoded. A `+` is a plus, not a space. */
694
+ private fun percentDecoded(value: String): String? =
695
+ runCatching { URLDecoder.decode(value.replace("+", "%2B"), "UTF-8") }.getOrNull()
696
+
611
697
  private fun refusedDownload(
612
698
  connection: HttpURLConnection,
613
699
  status: Int,
@@ -997,9 +1083,24 @@ internal class CraftNativeCapabilities(
997
1083
  return true
998
1084
  }
999
1085
 
1086
+ /**
1087
+ * A file name with no path in it that the disk will take, as UIKit's
1088
+ * `safeName` makes one: no leading dots (so never `..`, the folder
1089
+ * itself), and cut to fit. 255 bytes is what a name may be on disk and
1090
+ * 120 characters is where UIKit cuts; the extension stays, since it is
1091
+ * how a share sheet and an upload know what the file is.
1092
+ */
1000
1093
  private fun safeFileName(raw: String): String {
1001
- val name = raw.substringAfterLast('/').substringAfterLast('\\').trim()
1002
- return name.ifBlank { "file" }.replace(Regex("[\\\\/:*?\"<>|\\r\\n]"), "_")
1094
+ val name = raw.substringAfterLast('/').substringAfterLast('\\').trim().trimStart('.')
1095
+ val safe = name.replace(Regex("[\\\\/:*?\"<>|\\r\\n\\u0000]"), "_").ifBlank { "file" }
1096
+ fun fits(value: String) = value.codePointCount(0, value.length) <= 120 && value.toByteArray(Charsets.UTF_8).size <= 255
1097
+ if (fits(safe)) return safe
1098
+ val dot = safe.lastIndexOf('.')
1099
+ val extension = if (dot > 0 && safe.length - dot <= 16) safe.substring(dot) else ""
1100
+ var stem = safe.dropLast(extension.length)
1101
+ stem = stem.substring(0, stem.offsetByCodePoints(0, minOf(120, stem.codePointCount(0, stem.length))))
1102
+ while (stem.isNotEmpty() && !fits(stem + extension)) stem = stem.substring(0, stem.offsetByCodePoints(stem.length, -1))
1103
+ return stem + extension
1003
1104
  }
1004
1105
 
1005
1106
  private fun jpegName(raw: String): String {
@@ -1093,10 +1194,18 @@ internal class CraftNativeCapabilities(
1093
1194
  }
1094
1195
  }
1095
1196
 
1096
- private fun secureStorage(method: String, args: JSONArray): CraftNativeCapabilityResult {
1197
+ private fun secureStorage(method: String, args: JSONArray): CraftNativeCapabilityResult = synchronized(CraftNativeStorage.secureLock) {
1198
+ // A write that did not reach the disk is a failure, and nothing is told
1199
+ // it happened: the store's own memory is put back (a failed commit
1200
+ // has already changed it), no isolate hears of it, and the caller is
1201
+ // refused, as UIKit refuses a Keychain delete that failed. Answering
1202
+ // `false` and telling every isolate the token was gone made a failed
1203
+ // sign-out look like a sign-out until the next launch read the disk.
1097
1204
  if (method == "clear") {
1098
1205
  return try {
1099
- success(encryptedSecureStorage.edit().clear().commit().also { storeObserver?.invoke("secure", null, null) })
1206
+ if (!CraftNativeStorage.commit(encryptedSecureStorage) { it.clear() }) return failure("SECURE_STORAGE_ERROR", "Secure storage could not be cleared")
1207
+ storeObserver?.invoke("secure", null, null)
1208
+ success(true)
1100
1209
  } catch (error: Exception) {
1101
1210
  failure("SECURE_STORAGE_ERROR", error.message ?: "Secure storage could not be cleared")
1102
1211
  }
@@ -1106,14 +1215,18 @@ internal class CraftNativeCapabilities(
1106
1215
  when (method) {
1107
1216
  "set" -> {
1108
1217
  if (args.length() != 2 || args.opt(1) !is String) return failure("INVALID_ARGUMENT", "SecureStorage.set needs a key and string value")
1109
- if (encryptedSecureStorage.edit().putString(key, args.getString(1)).commit()) {
1110
- storeObserver?.invoke("secure", key, args.getString(1))
1218
+ val value = args.getString(1)
1219
+ if (CraftNativeStorage.commit(encryptedSecureStorage) { it.putString(key, value) }) {
1220
+ storeObserver?.invoke("secure", key, value)
1111
1221
  success(true)
1112
1222
  }
1113
1223
  else failure("SECURE_STORAGE_ERROR", "Secure storage could not be written")
1114
1224
  }
1115
1225
  "get" -> success(encryptedSecureStorage.getString(key, null) ?: JSONObject.NULL)
1116
- "remove" -> success(encryptedSecureStorage.edit().remove(key).commit().also { storeObserver?.invoke("secure", key, null) })
1226
+ "remove" -> if (CraftNativeStorage.commit(encryptedSecureStorage) { it.remove(key) }) {
1227
+ storeObserver?.invoke("secure", key, null)
1228
+ success(true)
1229
+ } else failure("SECURE_STORAGE_ERROR", "Secure storage could not be removed")
1117
1230
  else -> failure("UNKNOWN_ACTION", "Unsupported SecureStorage method")
1118
1231
  }
1119
1232
  } catch (error: Exception) {
@@ -294,6 +294,8 @@ internal class CraftNativeRoutes(config: JSONObject) {
294
294
  /** One process-wide boundary for committed storage reads and mutations. */
295
295
  internal object CraftNativeStorage {
296
296
  val lock = Any()
297
+ /** The secure store's writes, one at a time across every host in the process: a rollback restores what it read. */
298
+ val secureLock = Any()
297
299
  fun jsonEqual(left: Any?, right: Any?): Boolean {
298
300
  if (left == null || left === JSONObject.NULL) return right == null || right === JSONObject.NULL
299
301
  if (left is Boolean || right is Boolean) return left is Boolean && right is Boolean && left == right
@@ -365,6 +367,30 @@ internal class CraftNativeStores private constructor(
365
367
  cache
366
368
  }
367
369
 
370
+ /**
371
+ * Checkpoints that have been asked for and are not on disk yet, the
372
+ * newest per name; a null is one being deleted. A read finds these first,
373
+ * as UIKit's `CraftSnapshots.read` finds its `pending`, so a screen opened
374
+ * right after a checkpoint is seeded with it, and one opened right after
375
+ * a sign-out is not seeded with the account that just left (#507). The
376
+ * number is which request put the entry there. Guarded by itself.
377
+ */
378
+ private val pendingSnapshots = HashMap<String, Pair<Long, String?>>()
379
+ private var snapshotRevision = 0L
380
+
381
+ /** What a read gives now: the snapshots on disk with those on their way laid over them. */
382
+ private fun currentSnapshots(): Map<String, String> {
383
+ synchronized(pendingSnapshots) {
384
+ if (pendingSnapshots.isEmpty()) return snapshotCache
385
+ val current = HashMap(snapshotCache)
386
+ for ((name, waiting) in pendingSnapshots) {
387
+ val json = waiting.second
388
+ if (json == null) current.remove(name) else current[name] = json
389
+ }
390
+ return current
391
+ }
392
+ }
393
+
368
394
  /**
369
395
  * Everything an isolate needs before its first line runs, as JSON text:
370
396
  * `{ storage: { key: json }, secure: { key: value }, snapshots: { name: json } }`.
@@ -388,7 +414,7 @@ internal class CraftNativeStores private constructor(
388
414
  out.append(',')
389
415
  section("secure", runCatching { secure?.all?.mapNotNull { (key, value) -> (value as? String)?.let { key to it } }?.toMap() }.getOrNull() ?: emptyMap())
390
416
  out.append(',')
391
- section("snapshots", snapshotCache)
417
+ section("snapshots", currentSnapshots())
392
418
  out.append('}')
393
419
  return out.toString()
394
420
  }
@@ -396,7 +422,12 @@ internal class CraftNativeStores private constructor(
396
422
  /** The same, parsed (tests and small hosts). */
397
423
  fun preload(): JSONObject = JSONObject(preloadText())
398
424
 
399
- fun snapshot(name: String): String? = snapshotCache[name]
425
+ fun snapshot(name: String): String? {
426
+ synchronized(pendingSnapshots) {
427
+ val waiting = pendingSnapshots[name]
428
+ return if (waiting != null) waiting.second else snapshotCache[name]
429
+ }
430
+ }
400
431
 
401
432
  /**
402
433
  * One secure value, read directly rather than through the preload.
@@ -432,44 +463,72 @@ internal class CraftNativeStores private constructor(
432
463
  main.post { done(false) }
433
464
  return
434
465
  }
435
- writer.execute {
436
- val file = snapshotFile(name)
437
- val previous = snapshotCache[name]
438
- val ok = runCatching {
439
- if (json == null) {
440
- !file.exists() || file.delete()
441
- } else {
442
- snapshotFiles.set(file, json)
443
- true
466
+ // Admitting the write and queueing it are one step, as on UIKit:
467
+ // apart, an older request could reach the disk after a newer one
468
+ // while readers were already being given the newer value.
469
+ synchronized(pendingSnapshots) {
470
+ snapshotRevision += 1
471
+ val mine = snapshotRevision
472
+ pendingSnapshots[name] = mine to json
473
+ writer.execute {
474
+ val file = snapshotFile(name)
475
+ val ok = runCatching {
476
+ if (json == null) {
477
+ !file.exists() || file.delete()
478
+ } else {
479
+ snapshotFiles.set(file, json)
480
+ true
481
+ }
482
+ }.getOrDefault(false)
483
+ if (ok) {
484
+ if (json == null) snapshotCache.remove(name) else snapshotCache[name] = json
485
+ }
486
+ val current = synchronized(pendingSnapshots) {
487
+ if (pendingSnapshots[name]?.first == mine) pendingSnapshots.remove(name)
488
+ snapshot(name)
489
+ }
490
+ main.post {
491
+ // Isolates optimistically display their write. Each is
492
+ // told what a read gives now: the checkpoint when it was
493
+ // kept, the confirmed value when it was not, and a newer
494
+ // one still on its way in either case, so an older write
495
+ // settling cannot set a screen back behind it.
496
+ changed("snapshots", name, current, null)
497
+ done(ok)
444
498
  }
445
- }.getOrDefault(false)
446
- if (ok) {
447
- if (json == null) snapshotCache.remove(name) else snapshotCache[name] = json
448
- }
449
- main.post {
450
- // Isolates optimistically display their write. Restore the
451
- // confirmed value before rejecting an unsuccessful checkpoint.
452
- changed("snapshots", name, if (ok) json else previous, null)
453
- done(ok)
454
499
  }
455
500
  }
456
501
  }
457
502
 
458
503
  /** `craft.snapshots.clear(keep)`: every snapshot but those named, as a sign-out does. */
459
504
  fun clearSnapshots(keep: Set<String>, done: (Boolean) -> Unit) {
460
- writer.execute {
461
- val removed = mutableListOf<String>()
462
- var ok = true
463
- snapshotsDir.listFiles()?.forEach { file ->
464
- if (!file.name.endsWith(".json")) return@forEach
465
- val name = snapshotName(file) ?: return@forEach
466
- if (name in keep) return@forEach
467
- if (file.delete()) removed.add(name) else ok = false
468
- }
469
- removed.forEach(snapshotCache::remove)
470
- main.post {
471
- removed.forEach { changed("snapshots", it, null, null) }
472
- done(ok)
505
+ synchronized(pendingSnapshots) {
506
+ // Gone for a reader from this moment, as on UIKit, where a clear
507
+ // is a delete of every name.
508
+ snapshotRevision += 1
509
+ val mine = snapshotRevision
510
+ val doomed = currentSnapshots().keys.filter { it !in keep }
511
+ doomed.forEach { pendingSnapshots[it] = mine to null }
512
+ writer.execute {
513
+ val removed = mutableListOf<String>()
514
+ var ok = true
515
+ snapshotsDir.listFiles()?.forEach { file ->
516
+ if (!file.name.endsWith(".json")) return@forEach
517
+ val name = snapshotName(file) ?: return@forEach
518
+ if (name in keep) return@forEach
519
+ if (file.delete()) removed.add(name) else ok = false
520
+ }
521
+ removed.forEach(snapshotCache::remove)
522
+ val current = synchronized(pendingSnapshots) {
523
+ doomed.forEach { if (pendingSnapshots[it]?.first == mine) pendingSnapshots.remove(it) }
524
+ // One that could not be deleted is still there, and one
525
+ // written since the clear was asked for is on its way.
526
+ (removed + doomed).distinct().associateWith { snapshot(it) }
527
+ }
528
+ main.post {
529
+ current.forEach { (name, json) -> changed("snapshots", name, json, null) }
530
+ done(ok)
531
+ }
473
532
  }
474
533
  }
475
534
  }
@@ -912,6 +971,7 @@ internal class CraftNativeChrome(
912
971
  private val fonts: CraftNativeChromeFonts = CraftNativeChromeFonts(),
913
972
  ) : LinearLayout(context) {
914
973
  val content = FrameLayout(context)
974
+ private val body = FrameLayout(context)
915
975
  private val bar = LinearLayout(context)
916
976
  private val backButton = ImageView(context)
917
977
  private val leadingButtons = LinearLayout(context)
@@ -922,6 +982,7 @@ internal class CraftNativeChrome(
922
982
  private val tabRow = LinearLayout(context)
923
983
  private val accessory = FrameLayout(context)
924
984
  private val companionRow = LinearLayout(context)
985
+ private val accessoryGroup = LinearLayout(context)
925
986
  private val companionViews = mutableListOf<Pair<FrameLayout, JSONObject>>()
926
987
  private var renderedCompanionSpecs = ""
927
988
  private val accessoryIcon = ImageView(context)
@@ -1002,7 +1063,8 @@ internal class CraftNativeChrome(
1002
1063
  largeTitleView.ellipsize = TextUtils.TruncateAt.END
1003
1064
  largeTitleView.setPadding(dp(16), 0, dp(16), dp(8))
1004
1065
  addView(largeTitleView, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
1005
- addView(content, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f))
1066
+ body.addView(content, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
1067
+ addView(body, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f))
1006
1068
  tabBar.orientation = HORIZONTAL
1007
1069
  tabBar.gravity = Gravity.CENTER
1008
1070
  tabs.forEachIndexed { index, tab ->
@@ -1052,7 +1114,9 @@ internal class CraftNativeChrome(
1052
1114
  tabRow.addView(tabBar, LayoutParams(0, dp(64), 1f))
1053
1115
  companionRow.orientation = HORIZONTAL
1054
1116
  companionRow.gravity = Gravity.CENTER_VERTICAL
1055
- tabRow.addView(companionRow, LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(64)))
1117
+ accessoryGroup.orientation = HORIZONTAL
1118
+ accessoryGroup.gravity = Gravity.CENTER
1119
+ accessoryGroup.addView(companionRow, LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT))
1056
1120
  accessory.addView(accessoryIcon, FrameLayout.LayoutParams(dp(26), dp(26), Gravity.CENTER))
1057
1121
  accessoryBadge.textSize = 10f
1058
1122
  accessoryBadge.gravity = Gravity.CENTER
@@ -1062,7 +1126,8 @@ internal class CraftNativeChrome(
1062
1126
  accessory.addView(accessoryBadge, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(18), Gravity.TOP or Gravity.END))
1063
1127
  accessory.setOnClickListener { accessorySpec?.optString("path")?.let { onAccessory?.invoke(it) } }
1064
1128
  accessory.visibility = View.GONE
1065
- tabRow.addView(accessory, LayoutParams(dp(64), dp(64)).apply { marginStart = dp(8) })
1129
+ accessoryGroup.addView(accessory, LayoutParams(dp(64), dp(64)).apply { marginStart = dp(8) })
1130
+ tabRow.addView(accessoryGroup, LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT))
1066
1131
  addView(tabRow, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(72)))
1067
1132
  tabRow.visibility = if (tabs.isEmpty()) View.GONE else View.VISIBLE
1068
1133
  refreshColors()
@@ -1074,7 +1139,8 @@ internal class CraftNativeChrome(
1074
1139
  val overHorizontalScroll = onTabSwipeShouldDefer?.invoke(event.x, event.y) == true
1075
1140
  horizontalScrollDeferred = overHorizontalScroll
1076
1141
  if (overHorizontalScroll) parent?.requestDisallowInterceptTouchEvent(true)
1077
- swipeTracking = tabs.size > 1 && tabSwipeEnabled && !overHorizontalScroll && tabRow.visibility == View.VISIBLE && event.y < tabRow.top
1142
+ val overActions = android.graphics.Rect().let { accessoryGroup.getGlobalVisibleRect(it) && it.contains(event.rawX.toInt(), event.rawY.toInt()) }
1143
+ swipeTracking = tabs.size > 1 && tabSwipeEnabled && !overActions && !overHorizontalScroll && tabRow.visibility == View.VISIBLE && event.y < tabRow.top
1078
1144
  swipeIntercepted = false
1079
1145
  swipeDownX = event.x
1080
1146
  swipeDownY = event.y
@@ -1218,10 +1284,45 @@ internal class CraftNativeChrome(
1218
1284
  accessoryBadge.text = it.optString("badge").takeUnless { badge -> badge == "null" }.orEmpty().take(8)
1219
1285
  accessoryBadge.visibility = if (accessoryBadge.text.isEmpty()) View.GONE else View.VISIBLE
1220
1286
  }
1287
+ layoutAccessoryGroup(tabBarHidden)
1221
1288
  tabSwipeEnabled = options.optBoolean("tabSwipeEnabled", true)
1222
1289
  refreshColors()
1223
1290
  }
1224
1291
 
1292
+ /** The same actions and popup anchors survive switching between grouped and separate chrome. */
1293
+ private fun layoutAccessoryGroup(hidden: Boolean) {
1294
+ val layout = accessorySpec?.optString("layout").orEmpty()
1295
+ val grouped = layout == "vertical" || layout == "horizontal"
1296
+ val parent = if (grouped) body else tabRow
1297
+ if (accessoryGroup.parent !== parent) {
1298
+ (accessoryGroup.parent as? ViewGroup)?.removeView(accessoryGroup)
1299
+ if (grouped) body.addView(accessoryGroup, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.END).apply {
1300
+ marginEnd = dp(20)
1301
+ bottomMargin = dp(8)
1302
+ }) else tabRow.addView(accessoryGroup, LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT))
1303
+ }
1304
+ val vertical = layout == "vertical"
1305
+ accessoryGroup.orientation = if (vertical) VERTICAL else HORIZONTAL
1306
+ companionRow.orientation = accessoryGroup.orientation
1307
+ accessoryGroup.setPadding(if (grouped) dp(2) else 0, if (grouped) dp(2) else 0, if (grouped) dp(2) else 0, if (grouped) dp(2) else 0)
1308
+ accessoryGroup.visibility = if (accessorySpec == null || hidden || tabs.isEmpty()) View.GONE else View.VISIBLE
1309
+ accessoryGroup.tag = if (grouped) "craft-tab-accessory-group" else null
1310
+ accessoryGroup.elevation = if (grouped) dp(6).toFloat() else 0f
1311
+ accessoryGroup.clipToOutline = grouped
1312
+ companionViews.forEach { (action, _) ->
1313
+ action.layoutParams = LayoutParams(dp(48), dp(48)).apply { marginStart = if (grouped) 0 else dp(8) }
1314
+ }
1315
+ accessory.layoutParams = LayoutParams(dp(if (grouped) 48 else 64), dp(if (grouped) 48 else 64)).apply { marginStart = if (grouped) 0 else dp(8) }
1316
+ val divider = GradientDrawable().apply { setColor(Color.argb(48, 128, 128, 128)); setSize(dp(1), dp(1)) }
1317
+ for (row in listOf(accessoryGroup, companionRow)) {
1318
+ row.showDividers = if (grouped) SHOW_DIVIDER_MIDDLE else SHOW_DIVIDER_NONE
1319
+ row.dividerDrawable = if (grouped) divider.constantState?.newDrawable() else null
1320
+ row.dividerPadding = dp(12)
1321
+ }
1322
+ // An empty companion row must not leave a divider above a single icon.
1323
+ companionRow.visibility = if (companionViews.isEmpty()) View.GONE else View.VISIBLE
1324
+ }
1325
+
1225
1326
  fun selectTab(index: Int) {
1226
1327
  selectedTab = index
1227
1328
  refreshColors()
@@ -1316,16 +1417,18 @@ internal class CraftNativeChrome(
1316
1417
  largeTitleView.setTextColor(navigationTitleColor ?: text)
1317
1418
  backButton.imageTintList = ColorStateList.valueOf(accent)
1318
1419
  val surface = if (dark) Color.parseColor("#1C1C1E") else Color.parseColor("#F9F9F9")
1420
+ val grouped = accessoryGroup.parent === body
1421
+ accessoryGroup.background = if (grouped) GradientDrawable().apply { setColor(surface); cornerRadius = dp(26).toFloat() } else null
1319
1422
  tabBar.background = GradientDrawable().apply { setColor(surface); cornerRadius = dp(32).toFloat() }
1320
1423
  val primarySelected = accessorySpec?.let { isAccessorySelected(it) } == true
1321
1424
  accessory.isSelected = primarySelected
1322
1425
  companionViews.forEach { (action, item) ->
1323
1426
  val selected = isAccessorySelected(item)
1324
1427
  action.isSelected = selected
1325
- action.background = GradientDrawable().apply { setColor(if (selected) Color.argb(36, Color.red(accent), Color.green(accent), Color.blue(accent)) else surface); cornerRadius = dp(32).toFloat() }
1428
+ action.background = if (grouped) rippleBackground() else GradientDrawable().apply { setColor(if (selected) Color.argb(36, Color.red(accent), Color.green(accent), Color.blue(accent)) else surface); cornerRadius = dp(32).toFloat() }
1326
1429
  (action.getChildAt(0) as ImageView).setImageDrawable(CraftNativeIcons.drawable(context, null, item.optString("symbol"), if (selected) accent else text))
1327
1430
  }
1328
- accessory.background = GradientDrawable().apply { setColor(if (primarySelected) Color.argb(36, Color.red(accent), Color.green(accent), Color.blue(accent)) else surface); cornerRadius = dp(32).toFloat() }
1431
+ accessory.background = if (grouped) rippleBackground() else GradientDrawable().apply { setColor(if (primarySelected) Color.argb(36, Color.red(accent), Color.green(accent), Color.blue(accent)) else surface); cornerRadius = dp(32).toFloat() }
1329
1432
  accessorySpec?.let { accessoryIcon.setImageDrawable(CraftNativeIcons.drawable(context, null, it.optString("symbol"), if (primarySelected) accent else text)) }
1330
1433
  tabViews.forEachIndexed { index, (icon, label) ->
1331
1434
  tabBar.getChildAt(index).isSelected = index == selectedTab && !accessorySelected
@@ -26,7 +26,17 @@ internal class CraftNativeSounds(
26
26
  ) {
27
27
  private data class Sound(
28
28
  val player: MediaPlayer,
29
+ /** Where the player is right now, part-way through a fade included. */
29
30
  var volume: Float,
31
+ /**
32
+ * The volume that was asked for. A fade moves `volume` toward or away
33
+ * from this and must not overwrite it: with one number for both, a
34
+ * pause during a fade left the sound at whatever fraction the fade had
35
+ * reached, and `resume` played it there for the rest of its life —
36
+ * `play({ volume: 1, fadeIn: 2000 })` paused at 500 ms came back at
37
+ * 0.25. UIKit keeps the two apart and resumes at the asked-for volume.
38
+ */
39
+ var asked: Float,
30
40
  var paused: Boolean = false,
31
41
  var interrupted: Boolean = false,
32
42
  var prepared: Boolean = false,
@@ -66,7 +76,7 @@ internal class CraftNativeSounds(
66
76
  // Its player, pending load and cancellation handle still belong to it.
67
77
  stop(id, 0.0)
68
78
  val player = MediaPlayer()
69
- val sound = Sound(player, if (fadeIn > 0) 0f else volume, pending = done)
79
+ val sound = Sound(player, if (fadeIn > 0) 0f else volume, asked = volume, pending = done)
70
80
  sounds[id] = sound
71
81
  fun settle(result: CraftNativeCapabilityResult) {
72
82
  val answer = sound.pending ?: return
@@ -144,7 +154,19 @@ internal class CraftNativeSounds(
144
154
  fun resume(id: String): Boolean {
145
155
  val sound = sounds[id] ?: return false
146
156
  if (focusSuspended) return false
147
- if (sound.prepared) runCatching { sound.player.start() }.onFailure { return false }
157
+ // UIKit answers false for a sound that is not paused, and a caller
158
+ // reading the answer to learn whether anything was resumed should not
159
+ // get a different one here.
160
+ if (!sound.paused) return false
161
+ if (sound.prepared) {
162
+ // A pause cancels a fade where it stands. Land on the volume that
163
+ // was asked for rather than on the fraction the fade had reached.
164
+ // Left alone until the player is prepared: a fade-in that has not
165
+ // begun yet still starts from silence when it does.
166
+ sound.volume = sound.asked
167
+ applyVolume(sound)
168
+ runCatching { sound.player.start() }.onFailure { return false }
169
+ }
148
170
  sound.paused = false
149
171
  sound.interrupted = false
150
172
  return true
@@ -154,6 +176,7 @@ internal class CraftNativeSounds(
154
176
  val sound = sounds[id] ?: return false
155
177
  if (!volume.isFinite() || !fade.isFinite() || fade < 0) return false
156
178
  val target = volume.coerceIn(0.0, 1.0).toFloat()
179
+ sound.asked = target
157
180
  sound.fade?.let(main::removeCallbacks)
158
181
  sound.fade = null
159
182
  if (fade > 0 && !sound.paused) ramp(sound, target, fade / 1000.0)
package/dist/cli.js CHANGED
@@ -3246,7 +3246,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3246
3246
  `);
3247
3247
  }
3248
3248
  // package.json
3249
- var version = "0.0.491";
3249
+ var version = "0.0.492";
3250
3250
 
3251
3251
  // bin/cli.ts
3252
3252
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -376,7 +376,7 @@ function craftBinaryNotFoundMessage(triedPath) {
376
376
  `);
377
377
  }
378
378
  // package.json
379
- var version = "0.0.491";
379
+ var version = "0.0.492";
380
380
 
381
381
  // src/sdk-version.ts
382
382
  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.491";
120
+ var version = "0.0.492";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -819,6 +819,8 @@ final class CraftChrome: ObservableObject {
819
819
  var image: String? = nil
820
820
  /// A compact floating icon, instead of the system full-width accessory shelf.
821
821
  var iconOnly = false
822
+ /// A shared material capsule; nil keeps separate floating buttons.
823
+ var accessoryLayout: String? = nil
822
824
  var companions: [Tab] = []
823
825
  /// A tap opens these destinations in a compact menu instead of navigating.
824
826
  var menu: [TabMenuItem] = []
@@ -964,10 +966,11 @@ final class CraftChrome: ObservableObject {
964
966
  if let item, let path = item["path"] as? String,
965
967
  path.hasPrefix("/"), !path.hasPrefix("//"),
966
968
  let title = item["title"] as? String {
967
- next = Tab(id: path, title: title, symbol: item["symbol"] as? String ?? "message",
969
+ next = Tab(id: path, title: title, symbol: item["symbol"] as? String ?? "bubble.left",
968
970
  badge: (item["badge"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) },
969
971
  accessibilityLabel: item["accessibilityLabel"] as? String,
970
- iconOnly: item["iconOnly"] as? Bool ?? false,
972
+ iconOnly: (item["iconOnly"] as? Bool ?? false) || ["vertical", "horizontal"].contains(item["layout"] as? String ?? ""),
973
+ accessoryLayout: (item["layout"] as? String).flatMap { ["vertical", "horizontal"].contains($0) ? $0 : nil },
971
974
  companions: ((item["companions"] as? [[String: Any]]) ?? []).prefix(2).compactMap { action in
972
975
  guard let companionPath = action["path"] as? String,
973
976
  companionPath.hasPrefix("/"), !companionPath.hasPrefix("//"), companionPath != path,
@@ -1241,9 +1241,14 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1241
1241
  private var synchronizingTabs = false
1242
1242
  private var accessoryModel: CraftChrome.Tab?
1243
1243
  private let accessoryButton = UIButton(type: .system)
1244
+ // UIKit retains its shelf's content view across removal animations. Never
1245
+ // reparent that same button into floating chrome: the shelf can take it
1246
+ // back after our layout, leaving a perfectly sized but empty capsule.
1247
+ private let shelfAccessoryButton = UIButton(type: .system)
1244
1248
  private var companionButtons: [UIButton] = []
1245
1249
  private var companionModels: [CraftChrome.Tab] = []
1246
1250
  private var companionGlasses: [UIVisualEffectView] = []
1251
+ private var accessorySeparators: [UIView] = []
1247
1252
  private let accessoryBadge = UILabel()
1248
1253
  private let accessoryGlass = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
1249
1254
  private static let tabPictures = NSCache<NSURL, UIImage>()
@@ -1309,6 +1314,8 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1309
1314
  CraftChromeFonts.apply(to: tabBar)
1310
1315
  accessoryButton.accessibilityIdentifier = "craft-tab-accessory"
1311
1316
  accessoryButton.addTarget(self, action: #selector(openTabAccessory), for: .touchUpInside)
1317
+ shelfAccessoryButton.accessibilityIdentifier = "craft-tab-accessory"
1318
+ shelfAccessoryButton.addTarget(self, action: #selector(openTabAccessory), for: .touchUpInside)
1312
1319
  chromeSubscription = CraftChrome.shared.objectWillChange.sink { [weak self] in
1313
1320
  guard let self, !self.chromeUpdatePending else { return }
1314
1321
  self.chromeUpdatePending = true
@@ -1474,6 +1481,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1474
1481
  let button = UIButton(type: .system)
1475
1482
  var configuration = UIButton.Configuration.plain()
1476
1483
  configuration.image = UIImage(systemName: action.symbol)
1484
+ configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
1477
1485
  button.configuration = configuration
1478
1486
  button.accessibilityIdentifier = "craft-tab-companion:" + action.id
1479
1487
  button.accessibilityLabel = action.accessibilityLabel ?? action.title
@@ -1506,16 +1514,32 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1506
1514
  for (index, action) in companionModels.enumerated() {
1507
1515
  companionButtons[index].accessibilityTraits = chrome.accessorySelected(action) ? [.button, .selected] : [.button]
1508
1516
  }
1517
+ let grouped = model?.accessoryLayout != nil
1518
+ for glass in companionGlasses { glass.isHidden = grouped }
1519
+ for button in companionButtons { button.tintColor = grouped ? .label : nil }
1520
+ accessoryButton.tintColor = grouped ? .label : nil
1521
+ for separator in accessorySeparators { separator.removeFromSuperview() }
1522
+ accessorySeparators = grouped ? companionButtons.map { _ in
1523
+ let separator = UIView()
1524
+ separator.backgroundColor = .separator
1525
+ separator.isUserInteractionEnabled = false
1526
+ separator.isAccessibilityElement = false
1527
+ view.addSubview(separator)
1528
+ return separator
1529
+ } : []
1509
1530
  if let model {
1510
1531
  var configuration = UIButton.Configuration.plain()
1511
1532
  configuration.image = UIImage(systemName: model.symbol)
1533
+ configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
1512
1534
  configuration.title = model.iconOnly ? nil : model.displayTitle
1513
1535
  configuration.subtitle = model.iconOnly ? nil : model.badge
1514
1536
  configuration.imagePadding = model.iconOnly ? 0 : 8
1515
- accessoryButton.configuration = configuration
1516
- accessoryButton.accessibilityLabel = model.accessibilityLabel ?? model.displayTitle
1517
- accessoryButton.accessibilityValue = model.badge
1518
- accessoryButton.accessibilityTraits = chrome.accessorySelected(model) ? [.button, .selected] : [.button]
1537
+ for button in [accessoryButton, shelfAccessoryButton] {
1538
+ button.configuration = configuration
1539
+ button.accessibilityLabel = model.accessibilityLabel ?? model.displayTitle
1540
+ button.accessibilityValue = model.badge
1541
+ button.accessibilityTraits = chrome.accessorySelected(model) ? [.button, .selected] : [.button]
1542
+ }
1519
1543
  }
1520
1544
  #if compiler(>=6.2)
1521
1545
  if #available(iOS 26.0, *), model?.iconOnly != true {
@@ -1525,7 +1549,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1525
1549
  accessoryButton.removeFromSuperview()
1526
1550
  }
1527
1551
  if (model == nil) != (bottomAccessory == nil) {
1528
- bottomAccessory = model.map { _ in UITabAccessory(contentView: accessoryButton) }
1552
+ bottomAccessory = model.map { _ in UITabAccessory(contentView: shelfAccessoryButton) }
1529
1553
  }
1530
1554
  accessoryModel = model
1531
1555
  for nav in stacks.values { nav.additionalSafeAreaInsets.bottom = 0 }
@@ -1549,7 +1573,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1549
1573
  accessoryButton.isHidden = model == nil
1550
1574
  accessoryGlass.isHidden = model == nil
1551
1575
  accessoryGlass.isUserInteractionEnabled = false
1552
- accessoryGlass.layer.cornerRadius = 24
1576
+ accessoryGlass.layer.cornerRadius = grouped ? 26 : 24
1553
1577
  accessoryGlass.clipsToBounds = true
1554
1578
  accessoryButton.backgroundColor = .clear
1555
1579
  accessoryButton.layer.cornerRadius = 24
@@ -1563,6 +1587,15 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1563
1587
  accessoryBadge.clipsToBounds = true
1564
1588
  accessoryBadge.isAccessibilityElement = false
1565
1589
  accessoryModel = model
1590
+ // The shared material must stay behind every button, even after a
1591
+ // companion-only update creates new controls or a shelf changes mode.
1592
+ if accessoryGlass.superview === view {
1593
+ view.bringSubviewToFront(accessoryGlass)
1594
+ for button in companionButtons { view.bringSubviewToFront(button) }
1595
+ view.bringSubviewToFront(accessoryButton)
1596
+ for separator in accessorySeparators { view.bringSubviewToFront(separator) }
1597
+ view.bringSubviewToFront(accessoryBadge)
1598
+ }
1566
1599
  for nav in stacks.values { nav.additionalSafeAreaInsets.bottom = safeAreaForTabBar() }
1567
1600
  view.setNeedsLayout()
1568
1601
  }
@@ -1586,14 +1619,30 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1586
1619
  let iconOnly = accessoryModel?.iconOnly == true
1587
1620
  let height: CGFloat = iconOnly ? 48 : 44
1588
1621
  let width: CGFloat = iconOnly ? 48 : max(0, view.bounds.width - 32)
1589
- let x = iconOnly ? view.bounds.maxX - view.safeAreaInsets.right - width - 20 : 16
1622
+ let trailingX = view.effectiveUserInterfaceLayoutDirection == .rightToLeft
1623
+ ? view.safeAreaInsets.left + 20 : view.bounds.maxX - view.safeAreaInsets.right - width - 20
1624
+ let x = iconOnly ? trailingX : 16
1590
1625
  accessoryButton.frame = CGRect(x: x, y: tabBar.frame.minY - height - (iconOnly ? 8 : 0), width: width, height: height)
1591
1626
  accessoryGlass.frame = accessoryButton.frame
1627
+ let layout = accessoryModel?.accessoryLayout
1592
1628
  for (index, button) in companionButtons.enumerated() {
1593
- // Array order reads left to right, ending at the primary action.
1594
- button.frame = accessoryButton.frame.offsetBy(dx: -CGFloat(companionButtons.count - index) * (width + 12), dy: 0)
1629
+ let offset = CGFloat(companionButtons.count - index)
1630
+ if layout == "vertical" {
1631
+ button.frame = accessoryButton.frame.offsetBy(dx: 0, dy: -offset * height)
1632
+ } else {
1633
+ let direction: CGFloat = view.effectiveUserInterfaceLayoutDirection == .rightToLeft ? 1 : -1
1634
+ button.frame = accessoryButton.frame.offsetBy(dx: direction * offset * (width + (layout == nil ? 12 : 0)), dy: 0)
1635
+ }
1595
1636
  companionGlasses[index].frame = button.frame
1637
+ if layout != nil {
1638
+ accessoryGlass.frame = accessoryGlass.frame.union(button.frame)
1639
+ let line = 1 / (view.window?.screen.scale ?? 2)
1640
+ accessorySeparators[index].frame = layout == "vertical"
1641
+ ? CGRect(x: button.frame.minX + 12, y: button.frame.maxY - line / 2, width: width - 24, height: line)
1642
+ : CGRect(x: view.effectiveUserInterfaceLayoutDirection == .rightToLeft ? button.frame.minX : button.frame.maxX - line / 2, y: button.frame.minY + 12, width: line, height: height - 24)
1643
+ }
1596
1644
  }
1645
+ if layout != nil { accessoryGlass.frame = accessoryGlass.frame.insetBy(dx: -2, dy: -2) }
1597
1646
  let badgeWidth = max(18, accessoryBadge.intrinsicContentSize.width + 8)
1598
1647
  accessoryBadge.frame = CGRect(x: accessoryButton.frame.maxX - badgeWidth + 4, y: accessoryButton.frame.minY - 3, width: badgeWidth, height: 18)
1599
1648
  // A compact action floats over the page, not across its whole width.
@@ -2595,8 +2644,8 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
2595
2644
  // Craft's content swipe must not steal their drag/hold gestures.
2596
2645
  let point = gestureRecognizer.location(in: view)
2597
2646
  if !tabBar.isHidden, tabBar.frame.contains(point) { return false }
2598
- if !accessoryButton.isHidden, accessoryButton.window != nil,
2599
- accessoryButton.convert(accessoryButton.bounds, to: view).contains(point) { return false }
2647
+ if [accessoryButton, shelfAccessoryButton].contains(where: { !$0.isHidden && $0.window != nil && $0.convert($0.bounds, to: view).contains(point) }) { return false }
2648
+ if companionButtons.contains(where: { !$0.isHidden && $0.window != nil && $0.convert($0.bounds, to: view).contains(point) }) { return false }
2600
2649
  // A swipe may begin while the last one settles: it catches it.
2601
2650
  guard activeTabSwipeTarget == nil || tabSwipeSettling,
2602
2651
  hybrid.tabIds.count > 1, let nav = visibleStack, let top = nav.topViewController else { return false }
@@ -2007,8 +2007,9 @@ enum CraftSnapshots {
2007
2007
  // after a failure finds the previous value, because `pending`
2008
2008
  // was cleared above and the cache was only refilled on success.
2009
2009
  // Posting only on success left a failed checkpoint on screen as
2010
- // though it had been kept; Android already restores it
2011
- // (`changed("snapshots", name, if (ok) json else previous, null)`).
2010
+ // though it had been kept; Android restores it by sending what
2011
+ // a read gives once the write settles
2012
+ // (`changed("snapshots", name, current, null)`).
2012
2013
  NotificationCenter.default.post(name: changed, object: nil, userInfo: ["name": name])
2013
2014
  completion?(ok)
2014
2015
  }
@@ -126,12 +126,21 @@ enum CraftNativeFiles {
126
126
  return url
127
127
  }
128
128
 
129
- /// A file name with no path in it.
129
+ /// A file name with no path in it that the disk will take. A long name
130
+ /// is cut to fit and keeps its extension, which is how a share sheet and
131
+ /// an upload know what the file is.
130
132
  static func safeName(_ name: String) -> String {
131
133
  let parts = name.components(separatedBy: CharacterSet(charactersIn: "/\\:\u{0}\r\n"))
132
134
  var cleaned = parts.joined(separator: "-").trimmingCharacters(in: .whitespacesAndNewlines)
133
135
  while cleaned.hasPrefix(".") { cleaned.removeFirst() }
134
- return cleaned.isEmpty ? "file" : String(cleaned.prefix(120))
136
+ // 255 UTF-16 units is what the file system takes, and 120 characters
137
+ // of flags or family emoji is more than that.
138
+ guard cleaned.count > 120 || cleaned.utf16.count > 255 else { return cleaned.isEmpty ? "file" : cleaned }
139
+ let ending = (cleaned as NSString).pathExtension
140
+ let suffix = ending.isEmpty || ending.count > 15 ? "" : "." + ending
141
+ var stem = String(cleaned.dropLast(suffix.count).prefix(120 - suffix.count))
142
+ while (stem + suffix).utf16.count > 255, !stem.isEmpty { stem.removeLast() }
143
+ return stem + suffix
135
144
  }
136
145
 
137
146
  static func describe(_ url: URL, type: String, extra: [String: Any] = [:]) -> [String: Any] {
@@ -857,6 +866,30 @@ enum CraftNativeFiles {
857
866
  }
858
867
  }
859
868
 
869
+ /// Base64 as `atob` reads it, which is what the browser host runs and
870
+ /// what Android is held to: the alphabet, ASCII white space anywhere,
871
+ /// padding optional but right when it is there, and nothing else.
872
+ /// Foundation alone wants the padding present and lets a third `=` by.
873
+ static func decodeBase64(_ text: String) -> Data? {
874
+ var data = [UInt8]()
875
+ data.reserveCapacity(text.utf8.count + 2)
876
+ var padding = 0
877
+ for byte in text.utf8 {
878
+ switch byte {
879
+ case 0x20, 0x09, 0x0A, 0x0C, 0x0D: continue
880
+ case UInt8(ascii: "="): padding += 1
881
+ case _ where padding > 0: return nil
882
+ case UInt8(ascii: "A")...UInt8(ascii: "Z"), UInt8(ascii: "a")...UInt8(ascii: "z"),
883
+ UInt8(ascii: "0")...UInt8(ascii: "9"), UInt8(ascii: "+"), UInt8(ascii: "/"):
884
+ data.append(byte)
885
+ default: return nil
886
+ }
887
+ }
888
+ guard padding <= 2, data.count % 4 != 1, padding == 0 || (data.count + padding) % 4 == 0 else { return nil }
889
+ data.append(contentsOf: repeatElement(UInt8(ascii: "="), count: (4 - data.count % 4) % 4))
890
+ return Data(base64Encoded: Data(data))
891
+ }
892
+
860
893
  /// What a screen made itself (a CSV export, a route it built) written as
861
894
  /// a file for the share sheet: `data` is text (`encoding: 'utf8'`, the
862
895
  /// default, so a CSV crosses the bridge once and no larger) or base64
@@ -874,7 +907,7 @@ enum CraftNativeFiles {
874
907
  case "utf8":
875
908
  bytes = Data(text.utf8)
876
909
  case "base64":
877
- guard let decoded = Data(base64Encoded: text.filter { !$0.isWhitespace }) else {
910
+ guard let decoded = decodeBase64(text) else {
878
911
  return .failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.write was given base64 it could not read"))
879
912
  }
880
913
  bytes = decoded
@@ -9423,7 +9423,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9423
9423
  case ("Audio", "resume"):
9424
9424
  reply(.success(sounds.resume(id: id)))
9425
9425
  case ("Audio", "setVolume"):
9426
- reply(.success(sounds.setVolume(id: id, volume: number(1), fade: number(2, "fade") / 1000)))
9426
+ // A missing or non-numeric volume used to arrive here as 0 — the
9427
+ // `number` helper's fallback — which silenced the sound and
9428
+ // answered true. Android refuses it and leaves the sound as it
9429
+ // was (`!volume.isFinite() || !fade.isFinite() || fade < 0`), so a
9430
+ // malformed call muted on one host and did nothing on the other.
9431
+ let fade = number(2, "fade")
9432
+ guard args.count > 1, let volume = craftNativeNumber(args[1])?.doubleValue,
9433
+ volume.isFinite, fade.isFinite, fade >= 0 else {
9434
+ reply(.success(false))
9435
+ return true
9436
+ }
9437
+ reply(.success(sounds.setVolume(id: id, volume: volume, fade: fade / 1000)))
9427
9438
  case ("Audio", "stop"):
9428
9439
  reply(.success(sounds.stop(id: id, fadeOut: number(1, "fadeOut") / 1000)))
9429
9440
  case ("Audio", "stopAll"):
@@ -342,7 +342,19 @@ final class DriverTests: XCTestCase {
342
342
  }
343
343
 
344
344
  /// Deliver one gesture; the command's observation determines success.
345
- func press(_ element: XCUIElement) {
345
+ ///
346
+ /// Waits for the target to come to rest first (`settledFrame`) and returns
347
+ /// the frame it settled on. Every tap command goes through here, so a
348
+ /// control touched straight after a flick cannot be hit while the scroll
349
+ /// view is still decelerating — whichever command did the tapping. #449
350
+ /// settled only the boolean `tapid` branch, which left `tap`, `tapif`,
351
+ /// `tapexact` and non-boolean `tapid` with the same exposure.
352
+ ///
353
+ /// Waiting before the one gesture is not a second gesture, and for a
354
+ /// control already at rest this costs one 0.1s read.
355
+ @discardableResult
356
+ func press(_ element: XCUIElement) -> CGRect {
357
+ let settled = settledFrame(element)
346
358
  // Resolve this before activation: a menu item can disappear as soon
347
359
  // as its gesture completes (for example Select All).
348
360
  let input = [.textField, .secureTextField, .textView, .searchField].contains(element.elementType)
@@ -352,6 +364,7 @@ final class DriverTests: XCTestCase {
352
364
  // A tap on a read-only text view need not open the keyboard. Only a
353
365
  // following type command observes whether this target took focus.
354
366
  lastTappedInput = input
367
+ return settled
355
368
  }
356
369
 
357
370
  /// `target`'s frame once it has stopped moving, or its last reading at
@@ -541,8 +554,9 @@ final class DriverTests: XCTestCase {
541
554
  // renderer or hit-testing failure (HQ agreement job 7570).
542
555
  let before = target.value as? String
543
556
  let enabledBefore = target.isEnabled
544
- let frameBefore = settledFrame(target)
545
- press(target)
557
+ // press settles before it taps and hands back the frame it
558
+ // settled on, so this is one read rather than two.
559
+ let frameBefore = press(target)
546
560
  let settle = Date().addingTimeInterval(1.5)
547
561
  var flipped = false
548
562
  repeat {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.491",
3
+ "version": "0.0.492",
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>",