craft-native 0.0.491 → 0.0.496
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.
- package/dist/android/src/testing.d.ts +7 -0
- package/dist/android/templates/CraftNativeCapabilities.kt.template +132 -19
- package/dist/android/templates/CraftNativeShell.kt.template +142 -39
- package/dist/android/templates/CraftNativeSounds.kt.template +25 -2
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +5 -2
- package/dist/ios/templates/CraftHybrid.swift +60 -11
- package/dist/ios/templates/CraftNativeActions.swift +3 -2
- package/dist/ios/templates/CraftNativeFiles.swift +36 -3
- package/dist/ios/templates/CraftNativeScreen.swift +12 -1
- package/dist/ios/templates/testing/DriverTests.swift.template +17 -3
- package/package.json +1 -1
|
@@ -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" ->
|
|
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
|
-
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1110
|
-
|
|
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" ->
|
|
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",
|
|
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?
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3249
|
+
var version = "0.0.496";
|
|
3250
3250
|
|
|
3251
3251
|
// bin/cli.ts
|
|
3252
3252
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
|
@@ -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 ?? "
|
|
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
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
1594
|
-
|
|
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
|
|
2599
|
-
|
|
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
|
|
2011
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
545
|
-
|
|
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 {
|