craft-native 0.0.225 → 0.0.227
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/templates/CraftNativeImageCache.kt.template +1 -0
- package/dist/android/templates/CraftNativeServices.kt.template +1 -0
- package/dist/android/templates/CraftNativeShell.kt.template +81 -14
- package/dist/android/templates/MainActivityNative.kt.template +6 -0
- package/dist/browser.js +33 -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 +35 -7
- package/dist/ios/templates/CraftNativeActions.swift +12 -6
- package/dist/ios/templates/CraftNativeFiles.swift +37 -10
- package/dist/ios/templates/CraftNativeScreen.swift +2 -2
- package/dist/mobile-testing.js +4 -0
- package/dist/testing/media-cache/media-cache.kt +24 -3
- package/dist/testing/media-cache/media-cache.swift.fixture +20 -4
- package/package.json +1 -1
|
@@ -120,6 +120,7 @@ internal class CraftNativeServices(
|
|
|
120
120
|
done(success(CraftNativeMedia.prune(context, (0 until keep.length()).map { keep.optString(it) })))
|
|
121
121
|
}
|
|
122
122
|
"usage" -> done(success(CraftNativeMedia.usage(context)))
|
|
123
|
+
"clear" -> done(success(CraftNativeMedia.clear(context)))
|
|
123
124
|
else -> done(failure("UNKNOWN_ACTION", "Unsupported Media method"))
|
|
124
125
|
}
|
|
125
126
|
"Background" -> {
|
|
@@ -643,6 +643,10 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
643
643
|
private var accessorySpec: JSONObject? = null
|
|
644
644
|
private val tabViews = mutableListOf<Pair<TextView, TextView>>()
|
|
645
645
|
private val tabIconText = mutableMapOf<String, String>()
|
|
646
|
+
private val tabTitles = mutableMapOf<String, String>()
|
|
647
|
+
private val tabAccessibilityLabels = mutableMapOf<String, String>()
|
|
648
|
+
private val tabIconTints = mutableMapOf<String, Int>()
|
|
649
|
+
private val tabIconPulses = mutableSetOf<String>()
|
|
646
650
|
private val tabImageURLs = mutableMapOf<String, String>()
|
|
647
651
|
private val tabImages = mutableMapOf<String, android.graphics.drawable.Drawable>()
|
|
648
652
|
private val navButtonSpecs = mutableMapOf<TextView, JSONObject>()
|
|
@@ -706,6 +710,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
706
710
|
cell.gravity = Gravity.CENTER
|
|
707
711
|
cell.background = rippleBackground()
|
|
708
712
|
cell.contentDescription = tab.title
|
|
713
|
+
cell.tag = "craft-tab-${tab.id}"
|
|
709
714
|
val icon = TextView(context)
|
|
710
715
|
icon.text = CraftNativeSymbols.glyph(tab.symbol)
|
|
711
716
|
icon.textSize = 22f
|
|
@@ -716,6 +721,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
716
721
|
val label = TextView(context)
|
|
717
722
|
label.text = tab.title
|
|
718
723
|
label.textSize = 12f
|
|
724
|
+
label.fontFeatureSettings = "tnum"
|
|
719
725
|
label.gravity = Gravity.CENTER
|
|
720
726
|
label.isSingleLine = true
|
|
721
727
|
cell.addView(icon, LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
|
@@ -844,12 +850,25 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
844
850
|
val id = item.optString("id")
|
|
845
851
|
val index = tabs.indexOfFirst { it.id == id }
|
|
846
852
|
if (index < 0) return
|
|
847
|
-
|
|
848
|
-
|
|
853
|
+
if (item.has("accessibilityLabel")) {
|
|
854
|
+
tabAccessibilityLabels.remove(id)
|
|
855
|
+
item.optString("accessibilityLabel").takeUnless { item.isNull("accessibilityLabel") || it.isBlank() }?.let { tabAccessibilityLabels[id] = it.take(160) }
|
|
856
|
+
}
|
|
849
857
|
if (item.has("iconText")) {
|
|
850
858
|
tabIconText.remove(id)
|
|
851
859
|
item.optString("iconText").takeUnless { item.isNull("iconText") || it.isBlank() }?.let { tabIconText[id] = it.take(8) }
|
|
852
860
|
}
|
|
861
|
+
if (item.has("title")) {
|
|
862
|
+
tabTitles.remove(id)
|
|
863
|
+
item.optString("title").takeUnless { item.isNull("title") || it.isBlank() }?.let { tabTitles[id] = it.take(40) }
|
|
864
|
+
}
|
|
865
|
+
if (item.has("iconTint")) {
|
|
866
|
+
tabIconTints.remove(id)
|
|
867
|
+
if (!item.isNull("iconTint")) runCatching { Color.parseColor(item.optString("iconTint")) }.getOrNull()?.let { tabIconTints[id] = it }
|
|
868
|
+
}
|
|
869
|
+
if (item.has("iconPulse")) {
|
|
870
|
+
if (item.optBoolean("iconPulse", false)) tabIconPulses.add(id) else tabIconPulses.remove(id)
|
|
871
|
+
}
|
|
853
872
|
if (item.has("image")) {
|
|
854
873
|
val address = item.optString("image").takeIf { !item.isNull("image") && it.startsWith("https://") }
|
|
855
874
|
if (address != tabImageURLs[id]) {
|
|
@@ -896,16 +915,41 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
896
915
|
tabViews.forEachIndexed { index, (icon, label) ->
|
|
897
916
|
tabBar.getChildAt(index).isSelected = index == selectedTab
|
|
898
917
|
val color = if (index == selectedTab) accent else muted
|
|
899
|
-
val
|
|
900
|
-
|
|
918
|
+
val id = tabs[index].id
|
|
919
|
+
val number = tabIconText[id]
|
|
920
|
+
val iconColor = tabIconTints[id] ?: if (number != null) Color.parseColor("#FF2D55") else color
|
|
921
|
+
icon.setTextColor(iconColor)
|
|
922
|
+
label.text = tabTitles[id] ?: tabs[index].title
|
|
923
|
+
tabBar.getChildAt(index).contentDescription = tabAccessibilityLabels[id] ?: label.text
|
|
901
924
|
label.setTextColor(color)
|
|
902
|
-
val vector = tabImages[
|
|
925
|
+
val vector = tabImages[id] ?: if (number == null) CraftNativeIcons.drawable(context, null, tabs[index].symbol, iconColor) else null
|
|
926
|
+
val pulse = tabIconPulses.contains(id) && number == null && tabImages[id] == null && hasWindowFocus() && android.animation.ValueAnimator.areAnimatorsEnabled()
|
|
927
|
+
if (pulse && icon.animation == null) {
|
|
928
|
+
icon.startAnimation(android.view.animation.AlphaAnimation(1f, 0.55f).apply {
|
|
929
|
+
duration = 700
|
|
930
|
+
repeatCount = android.view.animation.Animation.INFINITE
|
|
931
|
+
repeatMode = android.view.animation.Animation.REVERSE
|
|
932
|
+
})
|
|
933
|
+
} else if (!pulse) {
|
|
934
|
+
icon.clearAnimation()
|
|
935
|
+
icon.alpha = 1f
|
|
936
|
+
}
|
|
903
937
|
icon.foreground = vector
|
|
904
938
|
icon.text = if (vector != null) "" else number ?: CraftNativeSymbols.glyph(tabs[index].symbol)
|
|
905
939
|
}
|
|
906
940
|
navButtonSpecs.forEach { (view, spec) -> applyNavButtonTint(view, spec, dark) }
|
|
907
941
|
}
|
|
908
942
|
|
|
943
|
+
override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
|
|
944
|
+
super.onWindowFocusChanged(hasWindowFocus)
|
|
945
|
+
if (hasWindowFocus) refreshColors() else tabViews.forEach { (icon, _) -> icon.clearAnimation() }
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
override fun onDetachedFromWindow() {
|
|
949
|
+
tabViews.forEach { (icon, _) -> icon.clearAnimation() }
|
|
950
|
+
super.onDetachedFromWindow()
|
|
951
|
+
}
|
|
952
|
+
|
|
909
953
|
private fun navButton(button: JSONObject): View {
|
|
910
954
|
val id = button.optString("id")
|
|
911
955
|
val title = button.optString("title")
|
|
@@ -1021,7 +1065,8 @@ internal object CraftNativeSymbols {
|
|
|
1021
1065
|
* A native Video given an address with a copy here plays the copy.
|
|
1022
1066
|
*/
|
|
1023
1067
|
internal object CraftNativeMedia {
|
|
1024
|
-
private val
|
|
1068
|
+
private data class Flight(val id: String, val waiting: MutableList<(CraftNativeCapabilityResult) -> Unit>)
|
|
1069
|
+
private val inFlight = HashMap<String, Flight>()
|
|
1025
1070
|
private val executor = Executors.newFixedThreadPool(2)
|
|
1026
1071
|
private val main = Handler(Looper.getMainLooper())
|
|
1027
1072
|
|
|
@@ -1054,12 +1099,12 @@ internal object CraftNativeMedia {
|
|
|
1054
1099
|
if (!cellular && metered(context)) {
|
|
1055
1100
|
return done(CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("reason", "cellular")))
|
|
1056
1101
|
}
|
|
1057
|
-
synchronized(inFlight) {
|
|
1102
|
+
val flight = synchronized(inFlight) {
|
|
1058
1103
|
inFlight[address]?.let { waiting ->
|
|
1059
|
-
waiting.add(done)
|
|
1104
|
+
waiting.waiting.add(done)
|
|
1060
1105
|
return
|
|
1061
1106
|
}
|
|
1062
|
-
inFlight[address] =
|
|
1107
|
+
Flight(java.util.UUID.randomUUID().toString(), mutableListOf(done)).also { inFlight[address] = it }
|
|
1063
1108
|
}
|
|
1064
1109
|
executor.execute {
|
|
1065
1110
|
val result = runCatching {
|
|
@@ -1073,7 +1118,7 @@ internal object CraftNativeMedia {
|
|
|
1073
1118
|
val target = file(context, address)
|
|
1074
1119
|
target.parentFile?.mkdirs()
|
|
1075
1120
|
// Written aside and moved into place, so a half-written file is never played.
|
|
1076
|
-
val staging = File(target.parentFile, "${target.name}.part")
|
|
1121
|
+
val staging = File(target.parentFile, "${target.name}.${flight.id}.part")
|
|
1077
1122
|
try {
|
|
1078
1123
|
val size = connection.inputStream.use { input -> staging.outputStream().use { input.copyTo(it) } }
|
|
1079
1124
|
val expected = connection.contentLengthLong
|
|
@@ -1081,6 +1126,9 @@ internal object CraftNativeMedia {
|
|
|
1081
1126
|
return@runCatching CraftNativeCapabilityResult(code = "NETWORK_ERROR", message = "media.keep received an incomplete file")
|
|
1082
1127
|
}
|
|
1083
1128
|
synchronized(inFlight) {
|
|
1129
|
+
if (inFlight[address]?.id != flight.id) {
|
|
1130
|
+
return@runCatching CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")
|
|
1131
|
+
}
|
|
1084
1132
|
if (!staging.renameTo(target)) {
|
|
1085
1133
|
return@runCatching CraftNativeCapabilityResult(code = "IO_ERROR", message = "media.keep could not store the file")
|
|
1086
1134
|
}
|
|
@@ -1098,7 +1146,7 @@ internal object CraftNativeMedia {
|
|
|
1098
1146
|
}
|
|
1099
1147
|
CraftNativeCapabilityResult(code = if (full) "NO_SPACE" else if (error is java.net.SocketTimeoutException) "TIMEOUT" else "NETWORK_ERROR", message = error.message ?: "media.keep failed")
|
|
1100
1148
|
}
|
|
1101
|
-
val waiting = synchronized(inFlight) { inFlight.remove(address)
|
|
1149
|
+
val waiting = synchronized(inFlight) { if (inFlight[address]?.id == flight.id) inFlight.remove(address)?.waiting.orEmpty() else emptyList() }
|
|
1102
1150
|
main.post { waiting.forEach { it(result) } }
|
|
1103
1151
|
}
|
|
1104
1152
|
}
|
|
@@ -1115,12 +1163,31 @@ internal object CraftNativeMedia {
|
|
|
1115
1163
|
var removed = 0
|
|
1116
1164
|
synchronized(inFlight) {
|
|
1117
1165
|
// A concurrent keep owns its target and staging file until it finishes.
|
|
1118
|
-
val active = inFlight.
|
|
1166
|
+
val active = inFlight.flatMap { (address, flight) ->
|
|
1167
|
+
val name = file(context, address).name
|
|
1168
|
+
listOf(name, "$name.${flight.id}.part")
|
|
1169
|
+
}.toSet()
|
|
1170
|
+
root(context).listFiles()?.forEach { file ->
|
|
1171
|
+
if (file.name !in active && file.name !in wanted && file.delete()) removed++
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
return removed
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/** Invalidates old transfers before deleting files; a later keep starts afresh. */
|
|
1178
|
+
fun clear(context: Context): Int {
|
|
1179
|
+
var removed = 0
|
|
1180
|
+
var failure: java.io.IOException? = null
|
|
1181
|
+
val waiting = synchronized(inFlight) {
|
|
1182
|
+
val callbacks = inFlight.values.flatMap { it.waiting }
|
|
1183
|
+
inFlight.clear()
|
|
1119
1184
|
root(context).listFiles()?.forEach { file ->
|
|
1120
|
-
|
|
1121
|
-
if (target !in active && file.name !in wanted && file.delete()) removed++
|
|
1185
|
+
if (file.delete()) removed++ else failure = java.io.IOException("The video cache could not be cleared")
|
|
1122
1186
|
}
|
|
1187
|
+
callbacks
|
|
1123
1188
|
}
|
|
1189
|
+
main.post { waiting.forEach { it(CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")) } }
|
|
1190
|
+
failure?.let { throw it }
|
|
1124
1191
|
return removed
|
|
1125
1192
|
}
|
|
1126
1193
|
|
|
@@ -4091,6 +4091,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
4091
4091
|
show(target)
|
|
4092
4092
|
} else {
|
|
4093
4093
|
host.removeView(target.view)
|
|
4094
|
+
target.view.translationX = 0f
|
|
4094
4095
|
current.view.translationX = 0f
|
|
4095
4096
|
chrome?.selectTab(currentTab)
|
|
4096
4097
|
tabSwipeTarget = null
|
|
@@ -4504,6 +4505,11 @@ class MainActivity : AppCompatActivity() {
|
|
|
4504
4505
|
* screen shown before hears true (its `navigation.onAppear`).
|
|
4505
4506
|
*/
|
|
4506
4507
|
private fun show(screen: Screen) {
|
|
4508
|
+
// A retained tab may have been the offscreen preview of a cancelled
|
|
4509
|
+
// swipe, or the outgoing view of a completed swipe. Its animator can
|
|
4510
|
+
// finish after the peer animator, so clear that ownership on display.
|
|
4511
|
+
screen.view.animate().withEndAction(null).setListener(null).cancel()
|
|
4512
|
+
screen.view.translationX = 0f
|
|
4507
4513
|
// A navigation breadcrumb: the trail a crash report carries.
|
|
4508
4514
|
if (visibleScreen !== screen) CraftCrashReporter.screenAppeared(screen.route ?: "home")
|
|
4509
4515
|
val bars = chrome
|
package/dist/browser.js
CHANGED
|
@@ -283,6 +283,13 @@ async function createBrowserPlatform(options = {}) {
|
|
|
283
283
|
};
|
|
284
284
|
const lifetime = new AbortController;
|
|
285
285
|
let account = new AbortController;
|
|
286
|
+
let mediaTransfers = new AbortController;
|
|
287
|
+
let mediaWrites = Promise.resolve();
|
|
288
|
+
const writeMedia = (work) => {
|
|
289
|
+
const write = mediaWrites.then(work);
|
|
290
|
+
mediaWrites = write.catch(() => {});
|
|
291
|
+
return write;
|
|
292
|
+
};
|
|
286
293
|
let mediaScope = persistence.scope();
|
|
287
294
|
let wakeLock = null;
|
|
288
295
|
let disposed = false;
|
|
@@ -365,6 +372,7 @@ async function createBrowserPlatform(options = {}) {
|
|
|
365
372
|
if (!("caches" in window))
|
|
366
373
|
return url;
|
|
367
374
|
const scope = persistence.scope();
|
|
375
|
+
const transfer = mediaTransfers.signal;
|
|
368
376
|
const existing = mediaURLs.get(url);
|
|
369
377
|
if (existing)
|
|
370
378
|
return existing;
|
|
@@ -372,7 +380,7 @@ async function createBrowserPlatform(options = {}) {
|
|
|
372
380
|
if (!saved)
|
|
373
381
|
return url;
|
|
374
382
|
const local = URL.createObjectURL(await saved.blob());
|
|
375
|
-
if (disposed || scope !== persistence.scope()) {
|
|
383
|
+
if (disposed || transfer.aborted || scope !== persistence.scope()) {
|
|
376
384
|
URL.revokeObjectURL(local);
|
|
377
385
|
return url;
|
|
378
386
|
}
|
|
@@ -629,6 +637,25 @@ async function createBrowserPlatform(options = {}) {
|
|
|
629
637
|
if (module === "Media") {
|
|
630
638
|
if (!("caches" in window))
|
|
631
639
|
return unsupported(module, method);
|
|
640
|
+
if (method === "clear") {
|
|
641
|
+
mediaTransfers.abort();
|
|
642
|
+
mediaTransfers = new AbortController;
|
|
643
|
+
const name = cacheName();
|
|
644
|
+
return writeMedia(async () => {
|
|
645
|
+
const cache2 = await caches.open(name);
|
|
646
|
+
let removed = 0;
|
|
647
|
+
for (const entry of await cache2.keys()) {
|
|
648
|
+
if (await cache2.delete(entry))
|
|
649
|
+
removed++;
|
|
650
|
+
const local = mediaURLs.get(entry.url);
|
|
651
|
+
if (local)
|
|
652
|
+
URL.revokeObjectURL(local);
|
|
653
|
+
mediaURLs.delete(entry.url);
|
|
654
|
+
}
|
|
655
|
+
return removed;
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
signal = AbortSignal.any([signal, mediaTransfers.signal]);
|
|
632
659
|
const cache = await caches.open(cacheName());
|
|
633
660
|
if (method === "keep") {
|
|
634
661
|
const url = String(args[0]);
|
|
@@ -646,7 +673,11 @@ async function createBrowserPlatform(options = {}) {
|
|
|
646
673
|
signal.throwIfAborted();
|
|
647
674
|
const headers = new Headers(response.headers);
|
|
648
675
|
headers.set("x-craft-size", String(body.size));
|
|
649
|
-
await
|
|
676
|
+
await writeMedia(async () => {
|
|
677
|
+
signal.throwIfAborted();
|
|
678
|
+
await cache.put(url, new Response(body, { headers, status: response.status }));
|
|
679
|
+
signal.throwIfAborted();
|
|
680
|
+
});
|
|
650
681
|
return { ok: true, url, path: url, size: body.size };
|
|
651
682
|
}
|
|
652
683
|
if (method === "path")
|
package/dist/cli.js
CHANGED
|
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3424
3424
|
`);
|
|
3425
3425
|
}
|
|
3426
3426
|
// package.json
|
|
3427
|
-
var version = "0.0.
|
|
3427
|
+
var version = "0.0.227";
|
|
3428
3428
|
|
|
3429
3429
|
// bin/cli.ts
|
|
3430
3430
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
|
@@ -616,6 +616,10 @@ final class CraftChrome: ObservableObject {
|
|
|
616
616
|
let symbol: String
|
|
617
617
|
var badge: String?
|
|
618
618
|
var iconText: String? = nil
|
|
619
|
+
var titleOverride: String? = nil
|
|
620
|
+
var iconTint: String? = nil
|
|
621
|
+
var iconPulse = false
|
|
622
|
+
var displayTitle: String { titleOverride ?? title }
|
|
619
623
|
var accessibilityLabel: String? = nil
|
|
620
624
|
var image: String? = nil
|
|
621
625
|
}
|
|
@@ -673,12 +677,21 @@ final class CraftChrome: ObservableObject {
|
|
|
673
677
|
/// the next screen shown says again. The page's own hideTabBar is apart.
|
|
674
678
|
private(set) var hiddenByScreen = false
|
|
675
679
|
|
|
676
|
-
/// Change one tab's icon without replacing
|
|
680
|
+
/// Change one tab's caption or icon without replacing stacks or selection.
|
|
677
681
|
func updateTab(_ item: [String: Any]) {
|
|
678
682
|
guard let id = item["id"] as? String, let index = tabs.firstIndex(where: { $0.id == id }) else { return }
|
|
679
683
|
if item.keys.contains("iconText") {
|
|
680
684
|
tabs[index].iconText = (item["iconText"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) }
|
|
681
685
|
}
|
|
686
|
+
if item.keys.contains("title") {
|
|
687
|
+
tabs[index].titleOverride = (item["title"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(40)) }
|
|
688
|
+
}
|
|
689
|
+
if item.keys.contains("iconTint") {
|
|
690
|
+
tabs[index].iconTint = (item["iconTint"] as? String).flatMap { UIColor(hex: $0) == nil ? nil : $0 }
|
|
691
|
+
}
|
|
692
|
+
if item.keys.contains("iconPulse") {
|
|
693
|
+
tabs[index].iconPulse = item["iconPulse"] as? Bool ?? false
|
|
694
|
+
}
|
|
682
695
|
if item.keys.contains("accessibilityLabel") {
|
|
683
696
|
tabs[index].accessibilityLabel = (item["accessibilityLabel"] as? String).map { String($0.prefix(160)) }
|
|
684
697
|
}
|
|
@@ -923,6 +936,7 @@ struct CraftTabBarView: View {
|
|
|
923
936
|
@ObservedObject private var chrome = CraftChrome.shared
|
|
924
937
|
@Namespace private var selection
|
|
925
938
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
939
|
+
@Environment(\.scenePhase) private var scenePhase
|
|
926
940
|
// Dynamic Type, relative to the styles UIKit's own bar uses. The bar is
|
|
927
941
|
// capped at the largest standard size (below), as UITabBar is; past that
|
|
928
942
|
// a long press shows the large content viewer instead.
|
|
@@ -1030,9 +1044,7 @@ struct CraftTabBarView: View {
|
|
|
1030
1044
|
.minimumScaleFactor(0.7)
|
|
1031
1045
|
.frame(height: iconSize + 6)
|
|
1032
1046
|
} else {
|
|
1033
|
-
|
|
1034
|
-
.font(.system(size: iconSize, weight: isSelected ? .semibold : .regular))
|
|
1035
|
-
.frame(height: iconSize + 6)
|
|
1047
|
+
tabSymbol(tab, selected: isSelected)
|
|
1036
1048
|
}
|
|
1037
1049
|
if let badge = tab.badge {
|
|
1038
1050
|
Text(badge)
|
|
@@ -1044,8 +1056,9 @@ struct CraftTabBarView: View {
|
|
|
1044
1056
|
.offset(x: 10, y: -4)
|
|
1045
1057
|
}
|
|
1046
1058
|
}
|
|
1047
|
-
Text(tab.
|
|
1059
|
+
Text(tab.displayTitle)
|
|
1048
1060
|
.font(.system(size: labelSize, weight: isSelected ? .semibold : .medium))
|
|
1061
|
+
.monospacedDigit()
|
|
1049
1062
|
.lineLimit(1)
|
|
1050
1063
|
.minimumScaleFactor(0.8)
|
|
1051
1064
|
}
|
|
@@ -1063,9 +1076,10 @@ struct CraftTabBarView: View {
|
|
|
1063
1076
|
.buttonStyle(.plain)
|
|
1064
1077
|
.accessibilityShowsLargeContentViewer {
|
|
1065
1078
|
Image(systemName: symbol(tab.symbol, selected: isSelected))
|
|
1066
|
-
Text(tab.
|
|
1079
|
+
Text(tab.displayTitle)
|
|
1067
1080
|
}
|
|
1068
|
-
.
|
|
1081
|
+
.accessibilityIdentifier("craft-tab-\(tab.id)")
|
|
1082
|
+
.accessibilityLabel(tab.accessibilityLabel ?? tab.badge.map { "\(tab.displayTitle), \($0)" } ?? tab.displayTitle)
|
|
1069
1083
|
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
|
|
1070
1084
|
}
|
|
1071
1085
|
}
|
|
@@ -1075,6 +1089,20 @@ struct CraftTabBarView: View {
|
|
|
1075
1089
|
chrome.tint.map { Color($0) } ?? Color.primary
|
|
1076
1090
|
}
|
|
1077
1091
|
|
|
1092
|
+
/// Live styling belongs to the icon, leaving selection and the caption clear.
|
|
1093
|
+
@ViewBuilder private func tabSymbol(_ tab: CraftChrome.Tab, selected: Bool) -> some View {
|
|
1094
|
+
let color = tab.iconTint.flatMap { UIColor(hex: $0) }.map { Color($0) } ?? (selected ? selectedColor : Color.primary)
|
|
1095
|
+
let icon = Image(systemName: symbol(tab.symbol, selected: selected))
|
|
1096
|
+
.font(.system(size: iconSize, weight: selected ? .semibold : .regular))
|
|
1097
|
+
.foregroundStyle(color)
|
|
1098
|
+
.frame(height: iconSize + 6)
|
|
1099
|
+
if #available(iOS 17.0, *) {
|
|
1100
|
+
icon.symbolEffect(.pulse, options: .repeating, isActive: tab.iconPulse && !reduceMotion && scenePhase == .active)
|
|
1101
|
+
} else {
|
|
1102
|
+
icon
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1078
1106
|
/// The filled variant for the current tab, when the symbol has one.
|
|
1079
1107
|
private func symbol(_ name: String, selected: Bool) -> String {
|
|
1080
1108
|
guard selected, !name.hasSuffix(".fill"), UIImage(systemName: "\(name).fill") != nil else { return name }
|
|
@@ -254,6 +254,12 @@ enum CraftNativeActions {
|
|
|
254
254
|
return
|
|
255
255
|
case ("Media", "usage"):
|
|
256
256
|
sync = .success(CraftNativeMedia.usage())
|
|
257
|
+
case ("Media", "clear"):
|
|
258
|
+
DispatchQueue.global(qos: .utility).async {
|
|
259
|
+
do { complete(requestToken, .success(try CraftNativeMedia.clear()), completion) }
|
|
260
|
+
catch { complete(requestToken, failure("IO_ERROR", "The video cache could not be cleared"), completion) }
|
|
261
|
+
}
|
|
262
|
+
return
|
|
257
263
|
case ("Lifecycle", "getState"):
|
|
258
264
|
sync = .success(currentAppState())
|
|
259
265
|
case ("DeepLinks", "getInitialURL"):
|
|
@@ -782,7 +788,7 @@ enum CraftNativeActions {
|
|
|
782
788
|
request.allowsExpensiveNetworkAccess = false
|
|
783
789
|
request.allowsConstrainedNetworkAccess = false
|
|
784
790
|
}
|
|
785
|
-
|
|
791
|
+
guard let flight = CraftNativeMedia.begin(address, { complete(requestToken, $0, completion) }) else { return }
|
|
786
792
|
let task = fetchSession.downloadTask(with: request) { location, response, error in
|
|
787
793
|
cancellationLock.lock()
|
|
788
794
|
fetchTasks.removeValue(forKey: requestToken)
|
|
@@ -790,25 +796,25 @@ enum CraftNativeActions {
|
|
|
790
796
|
if let error = error {
|
|
791
797
|
// Refused only because the connection is metered: not a failure, a wait.
|
|
792
798
|
if let reason = (error as? URLError)?.networkUnavailableReason, reason == .expensive || reason == .constrained {
|
|
793
|
-
CraftNativeMedia.finish(address, .success(["ok": false, "url": address, "reason": "cellular"]))
|
|
799
|
+
CraftNativeMedia.finish(address, flight: flight, .success(["ok": false, "url": address, "reason": "cellular"]))
|
|
794
800
|
return
|
|
795
801
|
}
|
|
796
802
|
let code = (error as? URLError)?.code == .timedOut ? "TIMEOUT" : "NETWORK_ERROR"
|
|
797
|
-
CraftNativeMedia.finish(address, failure(CraftNativeMedia.errorCode(error, fallback: code), error.localizedDescription))
|
|
803
|
+
CraftNativeMedia.finish(address, flight: flight, failure(CraftNativeMedia.errorCode(error, fallback: code), error.localizedDescription))
|
|
798
804
|
return
|
|
799
805
|
}
|
|
800
806
|
guard let http = response as? HTTPURLResponse, let location else {
|
|
801
|
-
CraftNativeMedia.finish(address, failure("NETWORK_ERROR", "media.keep received a response that is not HTTP"))
|
|
807
|
+
CraftNativeMedia.finish(address, flight: flight, failure("NETWORK_ERROR", "media.keep received a response that is not HTTP"))
|
|
802
808
|
return
|
|
803
809
|
}
|
|
804
810
|
// The system deletes `location` once this returns: move it now.
|
|
805
|
-
CraftNativeMedia.finish(address, CraftNativeMedia.store(address, at: location, response: http))
|
|
811
|
+
CraftNativeMedia.finish(address, flight: flight, CraftNativeMedia.store(address, at: location, response: http, flight: flight))
|
|
806
812
|
}
|
|
807
813
|
cancellationLock.lock()
|
|
808
814
|
let alreadyCancelled = cancelledRequests.contains(requestToken)
|
|
809
815
|
if !alreadyCancelled { fetchTasks[requestToken] = task }
|
|
810
816
|
cancellationLock.unlock()
|
|
811
|
-
if alreadyCancelled { CraftNativeMedia.finish(address, failure("CANCELLED", "media.keep was cancelled")) }
|
|
817
|
+
if alreadyCancelled { CraftNativeMedia.finish(address, flight: flight, failure("CANCELLED", "media.keep was cancelled")) }
|
|
812
818
|
else { task.resume() }
|
|
813
819
|
}
|
|
814
820
|
|
|
@@ -514,7 +514,11 @@ enum CraftNativeMedia {
|
|
|
514
514
|
|
|
515
515
|
private static let lock = NSLock()
|
|
516
516
|
/// Addresses being fetched now, so a second `keep` waits for the first.
|
|
517
|
-
private
|
|
517
|
+
private struct Flight {
|
|
518
|
+
let id: UUID
|
|
519
|
+
var waiting: [(Result<Any, CraftNativeActionError>) -> Void]
|
|
520
|
+
}
|
|
521
|
+
private static var inFlight: [String: Flight] = [:]
|
|
518
522
|
|
|
519
523
|
/// Where `address`'s copy lives (whether or not it is there yet). The
|
|
520
524
|
/// extension is the address's, because AVPlayer reads the type from it.
|
|
@@ -537,28 +541,29 @@ enum CraftNativeMedia {
|
|
|
537
541
|
return ["ok": true, "url": address, "path": url.path, "size": size]
|
|
538
542
|
}
|
|
539
543
|
|
|
540
|
-
///
|
|
541
|
-
static func
|
|
544
|
+
/// A new download's identity, or nil after joining an existing download.
|
|
545
|
+
static func begin(_ address: String, _ done: @escaping (Result<Any, CraftNativeActionError>) -> Void) -> UUID? {
|
|
542
546
|
lock.lock()
|
|
543
547
|
defer { lock.unlock() }
|
|
544
548
|
if inFlight[address] != nil {
|
|
545
|
-
inFlight[address]!.append(done)
|
|
546
|
-
return
|
|
549
|
+
inFlight[address]!.waiting.append(done)
|
|
550
|
+
return nil
|
|
547
551
|
}
|
|
548
|
-
|
|
549
|
-
|
|
552
|
+
let id = UUID()
|
|
553
|
+
inFlight[address] = Flight(id: id, waiting: [done])
|
|
554
|
+
return id
|
|
550
555
|
}
|
|
551
556
|
|
|
552
|
-
static func finish(_ address: String, _ result: Result<Any, CraftNativeActionError>) {
|
|
557
|
+
static func finish(_ address: String, flight: UUID, _ result: Result<Any, CraftNativeActionError>) {
|
|
553
558
|
lock.lock()
|
|
554
|
-
let waiting = inFlight.removeValue(forKey: address) ?? []
|
|
559
|
+
let waiting = inFlight[address]?.id == flight ? inFlight.removeValue(forKey: address)?.waiting ?? [] : []
|
|
555
560
|
lock.unlock()
|
|
556
561
|
for done in waiting { done(result) }
|
|
557
562
|
}
|
|
558
563
|
|
|
559
564
|
/// A finished download moved into place (atomically, so a half-written
|
|
560
565
|
/// file is never played), or a refusal with the server's status.
|
|
561
|
-
static func store(_ address: String, at location: URL, response: HTTPURLResponse) -> Result<Any, CraftNativeActionError> {
|
|
566
|
+
static func store(_ address: String, at location: URL, response: HTTPURLResponse, flight: UUID? = nil) -> Result<Any, CraftNativeActionError> {
|
|
562
567
|
guard response.statusCode == 200 else {
|
|
563
568
|
return .success(["ok": false, "url": address, "status": response.statusCode])
|
|
564
569
|
}
|
|
@@ -566,6 +571,9 @@ enum CraftNativeMedia {
|
|
|
566
571
|
// Commit and prune share a lock; neither can remove a file being committed.
|
|
567
572
|
lock.lock()
|
|
568
573
|
defer { lock.unlock() }
|
|
574
|
+
if let flight, inFlight[address]?.id != flight {
|
|
575
|
+
return .failure(CraftNativeActionError(code: "CANCELLED", message: "The video cache was cleared"))
|
|
576
|
+
}
|
|
569
577
|
let staging = root.appendingPathComponent(UUID().uuidString + ".part")
|
|
570
578
|
defer { try? fm.removeItem(at: staging) }
|
|
571
579
|
do {
|
|
@@ -596,6 +604,25 @@ enum CraftNativeMedia {
|
|
|
596
604
|
}
|
|
597
605
|
}
|
|
598
606
|
|
|
607
|
+
/// End an account's cache lifetime.
|
|
608
|
+
static func clear() throws -> Int {
|
|
609
|
+
lock.lock()
|
|
610
|
+
let waiting = inFlight.values.flatMap { $0.waiting }
|
|
611
|
+
inFlight.removeAll()
|
|
612
|
+
let fm = FileManager.default
|
|
613
|
+
let files = (try? fm.contentsOfDirectory(at: root, includingPropertiesForKeys: nil)) ?? []
|
|
614
|
+
var removed = 0
|
|
615
|
+
var failure: Error?
|
|
616
|
+
for url in files {
|
|
617
|
+
do { try fm.removeItem(at: url); removed += 1 }
|
|
618
|
+
catch { failure = error }
|
|
619
|
+
}
|
|
620
|
+
lock.unlock()
|
|
621
|
+
for done in waiting { done(.failure(CraftNativeActionError(code: "CANCELLED", message: "The video cache was cleared"))) }
|
|
622
|
+
if let failure { throw failure }
|
|
623
|
+
return removed
|
|
624
|
+
}
|
|
625
|
+
|
|
599
626
|
/// Removes every copy whose address is not in `keep`; answers how many.
|
|
600
627
|
static func prune(keep: [String]) -> Int {
|
|
601
628
|
lock.lock()
|
|
@@ -6071,7 +6071,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6071
6071
|
if let current = image.image { image.image = imageForDisplay(current, view: image) }
|
|
6072
6072
|
let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
|
|
6073
6073
|
image.contentMode = imageContentMode(requestedResizeMode)
|
|
6074
|
-
image.clipsToBounds = image.contentMode == .scaleAspectFill
|
|
6074
|
+
image.clipsToBounds = image.clipsToBounds || image.contentMode == .scaleAspectFill
|
|
6075
6075
|
updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: image)
|
|
6076
6076
|
updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: image)
|
|
6077
6077
|
updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: image)
|
|
@@ -6090,7 +6090,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6090
6090
|
if let current = background.image { background.image = imageForDisplay(current, view: background) }
|
|
6091
6091
|
let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
|
|
6092
6092
|
background.contentMode = imageContentMode(requestedResizeMode)
|
|
6093
|
-
background.clipsToBounds = background.contentMode == .scaleAspectFill
|
|
6093
|
+
background.clipsToBounds = background.clipsToBounds || background.contentMode == .scaleAspectFill
|
|
6094
6094
|
updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: background)
|
|
6095
6095
|
updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: background)
|
|
6096
6096
|
updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: background)
|
package/dist/mobile-testing.js
CHANGED
|
@@ -149,6 +149,8 @@ ${await Bun.file(join(fixtures, "media-cache.swift.fixture")).text()}`);
|
|
|
149
149
|
print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, "recover"]));
|
|
150
150
|
if (counts.get("/ios/slow.mp4") !== 1)
|
|
151
151
|
throw new Error("iOS duplicate fetch: keep did not coalesce");
|
|
152
|
+
if (counts.get("/ios/account/slow.mp4") !== 2)
|
|
153
|
+
throw new Error("iOS account switch did not start a fresh transfer");
|
|
152
154
|
print("PASS: iOS simulator interruption, restart, retry, prune and cached video decode");
|
|
153
155
|
}
|
|
154
156
|
}
|
|
@@ -232,6 +234,8 @@ ${await Bun.file(join(fixtures, "media-cache.kt")).text()}`);
|
|
|
232
234
|
await probe("recover");
|
|
233
235
|
if (counts.get("/android/slow.mp4") !== 1)
|
|
234
236
|
throw new Error("Android duplicate fetch: keep did not coalesce");
|
|
237
|
+
if (counts.get("/android/account/slow.mp4") !== 2)
|
|
238
|
+
throw new Error("Android account switch did not start a fresh transfer");
|
|
235
239
|
print("PASS: Android emulator interruption, restart, retry, prune and cached video decode");
|
|
236
240
|
}
|
|
237
241
|
}
|
|
@@ -75,10 +75,11 @@ class DownloadProbe : android.app.Instrumentation() {
|
|
|
75
75
|
"interrupt" -> {
|
|
76
76
|
CraftNativeMedia.prune(targetContext, emptyList())
|
|
77
77
|
CraftNativeMedia.keep(targetContext, restart, JSONObject(), true) {}
|
|
78
|
-
val
|
|
78
|
+
val target = CraftNativeMedia.file(targetContext, restart)
|
|
79
|
+
fun partial() = target.parentFile!!.listFiles()?.any { it.name.startsWith(target.name + ".") && it.name.endsWith(".part") && it.length() > 0 } == true
|
|
79
80
|
val until = System.currentTimeMillis() + 25000
|
|
80
|
-
while (
|
|
81
|
-
check(
|
|
81
|
+
while (!partial() && System.currentTimeMillis() < until) Thread.sleep(25)
|
|
82
|
+
check(partial(), "download is in progress before process death")
|
|
82
83
|
android.os.Process.killProcess(android.os.Process.myPid())
|
|
83
84
|
}
|
|
84
85
|
"recover" -> {
|
|
@@ -111,6 +112,26 @@ class DownloadProbe : android.app.Instrumentation() {
|
|
|
111
112
|
check(answers.size == 2 && answers.all { it }, "concurrent downloads coalesce and survive prune")
|
|
112
113
|
check(root.listFiles()?.none { it.name.endsWith(".part") } != false, "no abandoned staging files remain")
|
|
113
114
|
CraftNativeMedia.prune(targetContext, emptyList())
|
|
115
|
+
val privateVideo = "$base/account/slow.mp4"
|
|
116
|
+
val previous = java.util.concurrent.CountDownLatch(1)
|
|
117
|
+
var cancelled = false
|
|
118
|
+
CraftNativeMedia.keep(targetContext, privateVideo, JSONObject(), true) { cancelled = it.code == "CANCELLED"; previous.countDown() }
|
|
119
|
+
val privateTarget = CraftNativeMedia.file(targetContext, privateVideo)
|
|
120
|
+
fun downloading() = root.listFiles()?.any { it.name.startsWith(privateTarget.name + ".") && it.name.endsWith(".part") && it.length() > 0 } == true
|
|
121
|
+
val deadline = System.currentTimeMillis() + 25000
|
|
122
|
+
while (!downloading() && System.currentTimeMillis() < deadline) Thread.sleep(10)
|
|
123
|
+
check(downloading(), "the previous athlete's download is in progress before sign-out")
|
|
124
|
+
CraftNativeMedia.clear(targetContext)
|
|
125
|
+
check(previous.await(5, java.util.concurrent.TimeUnit.SECONDS) && cancelled, "sign-out cancels the previous athlete's download")
|
|
126
|
+
check(CraftNativeMedia.local(targetContext, privateVideo) == null, "signed-out video is not playable from cache")
|
|
127
|
+
check(keep(privateVideo), "the next athlete downloads the same URL in a new transfer")
|
|
128
|
+
val media = android.media.MediaMetadataRetriever()
|
|
129
|
+
try {
|
|
130
|
+
media.setDataSource(CraftNativeMedia.local(targetContext, privateVideo)!!.absolutePath)
|
|
131
|
+
check(media.getFrameAtTime(1000000) != null, "the next athlete's local video decodes after the old download settles")
|
|
132
|
+
} finally { media.release() }
|
|
133
|
+
CraftNativeMedia.clear(targetContext)
|
|
134
|
+
check(CraftNativeMedia.local(targetContext, privateVideo) == null, "sign-out removes the next athlete's cached copy too")
|
|
114
135
|
}
|
|
115
136
|
}
|
|
116
137
|
result.putString("stream", "${proof}PASS: ${args.getString("phase")} native download probe")
|
|
@@ -17,16 +17,16 @@ var latestTask: URLSessionTask?
|
|
|
17
17
|
var latestErrorCode: String?
|
|
18
18
|
func start(_ address: String, _ done: @escaping (Bool) -> Void) {
|
|
19
19
|
if let local = CraftNativeMedia.local(address) { done((CraftNativeMedia.describe(address, local)["size"] as? Int ?? 0) > 0); return }
|
|
20
|
-
|
|
20
|
+
guard let flight = CraftNativeMedia.begin(address, { result in
|
|
21
21
|
if case .success(let data) = result { done((data as? [String: Any])?["ok"] as? Bool == true) }
|
|
22
22
|
else if case .failure(let error) = result { latestErrorCode = error.code; done(false) }
|
|
23
|
-
}) { return }
|
|
23
|
+
}) else { return }
|
|
24
24
|
let task = URLSession.shared.downloadTask(with: URL(string: address)!) { location, response, error in
|
|
25
25
|
let result: Result<Any, CraftNativeActionError>
|
|
26
26
|
if error != nil { result = .failure(CraftNativeActionError(code: "NETWORK_ERROR", message: "Interrupted")) }
|
|
27
|
-
else if let location, let response = response as? HTTPURLResponse { result = CraftNativeMedia.store(address, at: location, response: response) }
|
|
27
|
+
else if let location, let response = response as? HTTPURLResponse { result = CraftNativeMedia.store(address, at: location, response: response, flight: flight) }
|
|
28
28
|
else { result = .failure(CraftNativeActionError(code: "NETWORK_ERROR", message: "No file")) }
|
|
29
|
-
CraftNativeMedia.finish(address, result)
|
|
29
|
+
CraftNativeMedia.finish(address, flight: flight, result)
|
|
30
30
|
}
|
|
31
31
|
latestTask = task
|
|
32
32
|
task.resume()
|
|
@@ -116,3 +116,19 @@ check(answers == [true, true], "concurrent downloads coalesce and survive prune"
|
|
|
116
116
|
let parts = (try? fm.contentsOfDirectory(atPath: CraftNativeMedia.root.path))?.filter { $0.hasSuffix(".part") } ?? []
|
|
117
117
|
check(parts.isEmpty, "no abandoned staging files remain")
|
|
118
118
|
_ = CraftNativeMedia.prune(keep: [])
|
|
119
|
+
let privateVideo = base + "/account/slow.mp4"
|
|
120
|
+
var previousAnswer: Bool?
|
|
121
|
+
start(privateVideo) { previousAnswer = $0 }
|
|
122
|
+
let previousTask = latestTask!
|
|
123
|
+
wait { previousTask.countOfBytesReceived > 0 }
|
|
124
|
+
_ = try CraftNativeMedia.clear()
|
|
125
|
+
check(previousAnswer == false && latestErrorCode == "CANCELLED", "sign-out cancels the previous athlete's download")
|
|
126
|
+
check(CraftNativeMedia.local(privateVideo) == nil, "signed-out video is not playable from cache")
|
|
127
|
+
check(keep(privateVideo), "the next athlete downloads the same URL in a new transfer")
|
|
128
|
+
wait { previousTask.state == .completed }
|
|
129
|
+
let cached = CraftNativeMedia.local(privateVideo)!
|
|
130
|
+
let generator = AVAssetImageGenerator(asset: AVURLAsset(url: cached))
|
|
131
|
+
_ = try generator.copyCGImage(at: CMTime(seconds: 1, preferredTimescale: 600), actualTime: nil)
|
|
132
|
+
check(true, "the next athlete's local video decodes after the old download settles")
|
|
133
|
+
_ = try CraftNativeMedia.clear()
|
|
134
|
+
check(CraftNativeMedia.local(privateVideo) == nil, "sign-out removes the next athlete's cached copy too")
|