craft-native 0.0.490 → 0.0.492
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +328 -39
- package/dist/android/templates/CraftNativeSounds.kt.template +25 -2
- package/dist/android/templates/MainActivityNative.kt.template +23 -0
- package/dist/cli.js +6 -3
- package/dist/index.cjs +6 -3
- package/dist/index.js +6 -3
- 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/CraftNativeServices.swift +35 -0
- package/dist/ios/templates/CraftVoiceRecording.swift +17 -1
- package/dist/ios/templates/CraftWebAudio.swift +77 -12
- package/dist/ios/templates/testing/DriverTests.swift.template +47 -3
- package/dist/package.d.ts +5 -0
- package/package.json +1 -1
|
@@ -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.
|
|
@@ -425,44 +456,79 @@ internal class CraftNativeStores private constructor(
|
|
|
425
456
|
main.post { done(false) }
|
|
426
457
|
return
|
|
427
458
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
459
|
+
// UIKit refuses a payload that is not JSON before it touches the
|
|
460
|
+
// disk, and a checkpoint kept on one host and refused on the other is
|
|
461
|
+
// the divergence this contract exists to prevent (#396).
|
|
462
|
+
if (json != null && !craftNativeIsJsonText(json)) {
|
|
463
|
+
main.post { done(false) }
|
|
464
|
+
return
|
|
465
|
+
}
|
|
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)
|
|
437
498
|
}
|
|
438
|
-
}.getOrDefault(false)
|
|
439
|
-
if (ok) {
|
|
440
|
-
if (json == null) snapshotCache.remove(name) else snapshotCache[name] = json
|
|
441
|
-
}
|
|
442
|
-
main.post {
|
|
443
|
-
// Isolates optimistically display their write. Restore the
|
|
444
|
-
// confirmed value before rejecting an unsuccessful checkpoint.
|
|
445
|
-
changed("snapshots", name, if (ok) json else previous, null)
|
|
446
|
-
done(ok)
|
|
447
499
|
}
|
|
448
500
|
}
|
|
449
501
|
}
|
|
450
502
|
|
|
451
503
|
/** `craft.snapshots.clear(keep)`: every snapshot but those named, as a sign-out does. */
|
|
452
504
|
fun clearSnapshots(keep: Set<String>, done: (Boolean) -> Unit) {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
+
}
|
|
466
532
|
}
|
|
467
533
|
}
|
|
468
534
|
}
|
|
@@ -905,6 +971,7 @@ internal class CraftNativeChrome(
|
|
|
905
971
|
private val fonts: CraftNativeChromeFonts = CraftNativeChromeFonts(),
|
|
906
972
|
) : LinearLayout(context) {
|
|
907
973
|
val content = FrameLayout(context)
|
|
974
|
+
private val body = FrameLayout(context)
|
|
908
975
|
private val bar = LinearLayout(context)
|
|
909
976
|
private val backButton = ImageView(context)
|
|
910
977
|
private val leadingButtons = LinearLayout(context)
|
|
@@ -915,6 +982,7 @@ internal class CraftNativeChrome(
|
|
|
915
982
|
private val tabRow = LinearLayout(context)
|
|
916
983
|
private val accessory = FrameLayout(context)
|
|
917
984
|
private val companionRow = LinearLayout(context)
|
|
985
|
+
private val accessoryGroup = LinearLayout(context)
|
|
918
986
|
private val companionViews = mutableListOf<Pair<FrameLayout, JSONObject>>()
|
|
919
987
|
private var renderedCompanionSpecs = ""
|
|
920
988
|
private val accessoryIcon = ImageView(context)
|
|
@@ -995,7 +1063,8 @@ internal class CraftNativeChrome(
|
|
|
995
1063
|
largeTitleView.ellipsize = TextUtils.TruncateAt.END
|
|
996
1064
|
largeTitleView.setPadding(dp(16), 0, dp(16), dp(8))
|
|
997
1065
|
addView(largeTitleView, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
|
998
|
-
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))
|
|
999
1068
|
tabBar.orientation = HORIZONTAL
|
|
1000
1069
|
tabBar.gravity = Gravity.CENTER
|
|
1001
1070
|
tabs.forEachIndexed { index, tab ->
|
|
@@ -1045,7 +1114,9 @@ internal class CraftNativeChrome(
|
|
|
1045
1114
|
tabRow.addView(tabBar, LayoutParams(0, dp(64), 1f))
|
|
1046
1115
|
companionRow.orientation = HORIZONTAL
|
|
1047
1116
|
companionRow.gravity = Gravity.CENTER_VERTICAL
|
|
1048
|
-
|
|
1117
|
+
accessoryGroup.orientation = HORIZONTAL
|
|
1118
|
+
accessoryGroup.gravity = Gravity.CENTER
|
|
1119
|
+
accessoryGroup.addView(companionRow, LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
|
1049
1120
|
accessory.addView(accessoryIcon, FrameLayout.LayoutParams(dp(26), dp(26), Gravity.CENTER))
|
|
1050
1121
|
accessoryBadge.textSize = 10f
|
|
1051
1122
|
accessoryBadge.gravity = Gravity.CENTER
|
|
@@ -1055,7 +1126,8 @@ internal class CraftNativeChrome(
|
|
|
1055
1126
|
accessory.addView(accessoryBadge, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(18), Gravity.TOP or Gravity.END))
|
|
1056
1127
|
accessory.setOnClickListener { accessorySpec?.optString("path")?.let { onAccessory?.invoke(it) } }
|
|
1057
1128
|
accessory.visibility = View.GONE
|
|
1058
|
-
|
|
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))
|
|
1059
1131
|
addView(tabRow, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(72)))
|
|
1060
1132
|
tabRow.visibility = if (tabs.isEmpty()) View.GONE else View.VISIBLE
|
|
1061
1133
|
refreshColors()
|
|
@@ -1067,7 +1139,8 @@ internal class CraftNativeChrome(
|
|
|
1067
1139
|
val overHorizontalScroll = onTabSwipeShouldDefer?.invoke(event.x, event.y) == true
|
|
1068
1140
|
horizontalScrollDeferred = overHorizontalScroll
|
|
1069
1141
|
if (overHorizontalScroll) parent?.requestDisallowInterceptTouchEvent(true)
|
|
1070
|
-
|
|
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
|
|
1071
1144
|
swipeIntercepted = false
|
|
1072
1145
|
swipeDownX = event.x
|
|
1073
1146
|
swipeDownY = event.y
|
|
@@ -1211,10 +1284,45 @@ internal class CraftNativeChrome(
|
|
|
1211
1284
|
accessoryBadge.text = it.optString("badge").takeUnless { badge -> badge == "null" }.orEmpty().take(8)
|
|
1212
1285
|
accessoryBadge.visibility = if (accessoryBadge.text.isEmpty()) View.GONE else View.VISIBLE
|
|
1213
1286
|
}
|
|
1287
|
+
layoutAccessoryGroup(tabBarHidden)
|
|
1214
1288
|
tabSwipeEnabled = options.optBoolean("tabSwipeEnabled", true)
|
|
1215
1289
|
refreshColors()
|
|
1216
1290
|
}
|
|
1217
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
|
+
|
|
1218
1326
|
fun selectTab(index: Int) {
|
|
1219
1327
|
selectedTab = index
|
|
1220
1328
|
refreshColors()
|
|
@@ -1309,16 +1417,18 @@ internal class CraftNativeChrome(
|
|
|
1309
1417
|
largeTitleView.setTextColor(navigationTitleColor ?: text)
|
|
1310
1418
|
backButton.imageTintList = ColorStateList.valueOf(accent)
|
|
1311
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
|
|
1312
1422
|
tabBar.background = GradientDrawable().apply { setColor(surface); cornerRadius = dp(32).toFloat() }
|
|
1313
1423
|
val primarySelected = accessorySpec?.let { isAccessorySelected(it) } == true
|
|
1314
1424
|
accessory.isSelected = primarySelected
|
|
1315
1425
|
companionViews.forEach { (action, item) ->
|
|
1316
1426
|
val selected = isAccessorySelected(item)
|
|
1317
1427
|
action.isSelected = selected
|
|
1318
|
-
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() }
|
|
1319
1429
|
(action.getChildAt(0) as ImageView).setImageDrawable(CraftNativeIcons.drawable(context, null, item.optString("symbol"), if (selected) accent else text))
|
|
1320
1430
|
}
|
|
1321
|
-
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() }
|
|
1322
1432
|
accessorySpec?.let { accessoryIcon.setImageDrawable(CraftNativeIcons.drawable(context, null, it.optString("symbol"), if (primarySelected) accent else text)) }
|
|
1323
1433
|
tabViews.forEachIndexed { index, (icon, label) ->
|
|
1324
1434
|
tabBar.getChildAt(index).isSelected = index == selectedTab && !accessorySelected
|
|
@@ -2140,3 +2250,182 @@ internal object CraftNativeIcons {
|
|
|
2140
2250
|
override fun getOpacity(): Int = android.graphics.PixelFormat.TRANSLUCENT
|
|
2141
2251
|
}
|
|
2142
2252
|
}
|
|
2253
|
+
|
|
2254
|
+
/**
|
|
2255
|
+
* Whether `text` is JSON that UIKit's own writer would also accept.
|
|
2256
|
+
*
|
|
2257
|
+
* UIKit refuses a snapshot whose payload is not JSON, before it touches the
|
|
2258
|
+
* disk: `JSONSerialization.jsonObject(with:options: [.fragmentsAllowed])`
|
|
2259
|
+
* returning nil fails the write. Android had no such check, so a payload iOS
|
|
2260
|
+
* rejected was written here and the two hosts disagreed about whether a
|
|
2261
|
+
* checkpoint had been kept (#396).
|
|
2262
|
+
*
|
|
2263
|
+
* The grammar is Foundation's, measured rather than assumed, because it is
|
|
2264
|
+
* not RFC 8259 and a strict parser would have diverged in the other
|
|
2265
|
+
* direction:
|
|
2266
|
+
*
|
|
2267
|
+
* - top-level fragments are allowed (`42`, `"text"`, `null`);
|
|
2268
|
+
* - **one** trailing comma is allowed in an object or array — `{"a":1,}`
|
|
2269
|
+
* and `[1,]` are accepted, `{"a":1,,}` and `[,1]` are not;
|
|
2270
|
+
* - a lone surrogate escape is rejected, a matched pair accepted;
|
|
2271
|
+
* - a raw control character inside a string is rejected, its escape is not;
|
|
2272
|
+
* - `00`, `+1`, `.5`, `1.`, `1.0e`, `NaN` and `Infinity` are all rejected;
|
|
2273
|
+
* - trailing content after the value is rejected, surrounding space is not.
|
|
2274
|
+
*
|
|
2275
|
+
* `org.json` cannot stand in for this: `JSONTokener` accepts unquoted keys,
|
|
2276
|
+
* single quotes and more, so it would admit payloads UIKit refuses.
|
|
2277
|
+
* `android.util.JsonReader` in strict mode refuses top-level fragments that
|
|
2278
|
+
* UIKit accepts. Both are wrong in a direction that matters, which is why
|
|
2279
|
+
* this is scanned here and pinned against Foundation itself by
|
|
2280
|
+
* `packages/android/src/snapshot-json-contract.test.ts`.
|
|
2281
|
+
*/
|
|
2282
|
+
private class CraftNativeJsonScanner(private val text: String) {
|
|
2283
|
+
private var at = 0
|
|
2284
|
+
|
|
2285
|
+
fun validate(): Boolean {
|
|
2286
|
+
skipWhitespace()
|
|
2287
|
+
if (!value()) return false
|
|
2288
|
+
skipWhitespace()
|
|
2289
|
+
return at == text.length
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
private fun skipWhitespace() {
|
|
2293
|
+
while (at < text.length && (text[at] == ' ' || text[at] == '\t' || text[at] == '\n' || text[at] == '\r')) at++
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
private fun value(): Boolean {
|
|
2297
|
+
if (at >= text.length) return false
|
|
2298
|
+
return when (text[at]) {
|
|
2299
|
+
'{' -> obj()
|
|
2300
|
+
'[' -> arr()
|
|
2301
|
+
'"' -> string()
|
|
2302
|
+
't' -> literal("true")
|
|
2303
|
+
'f' -> literal("false")
|
|
2304
|
+
'n' -> literal("null")
|
|
2305
|
+
else -> number()
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
private fun literal(word: String): Boolean {
|
|
2310
|
+
if (!text.startsWith(word, at)) return false
|
|
2311
|
+
at += word.length
|
|
2312
|
+
return true
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
/** After a member, consume the separator. Returns null when malformed. */
|
|
2316
|
+
private fun afterMember(closing: Char): Boolean? {
|
|
2317
|
+
skipWhitespace()
|
|
2318
|
+
if (at >= text.length) return null
|
|
2319
|
+
return when (text[at]) {
|
|
2320
|
+
',' -> {
|
|
2321
|
+
at++
|
|
2322
|
+
skipWhitespace()
|
|
2323
|
+
// Foundation allows exactly one trailing comma before the
|
|
2324
|
+
// close. A second comma, or a close that never comes, is not.
|
|
2325
|
+
if (at < text.length && text[at] == closing) { at++; true } else false
|
|
2326
|
+
}
|
|
2327
|
+
closing -> { at++; true }
|
|
2328
|
+
else -> null
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
private fun obj(): Boolean {
|
|
2333
|
+
at++
|
|
2334
|
+
skipWhitespace()
|
|
2335
|
+
if (at < text.length && text[at] == '}') { at++; return true }
|
|
2336
|
+
while (true) {
|
|
2337
|
+
skipWhitespace()
|
|
2338
|
+
if (at >= text.length || text[at] != '"') return false
|
|
2339
|
+
if (!string()) return false
|
|
2340
|
+
skipWhitespace()
|
|
2341
|
+
if (at >= text.length || text[at] != ':') return false
|
|
2342
|
+
at++
|
|
2343
|
+
skipWhitespace()
|
|
2344
|
+
if (!value()) return false
|
|
2345
|
+
val done = afterMember('}') ?: return false
|
|
2346
|
+
if (done) return true
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
private fun arr(): Boolean {
|
|
2351
|
+
at++
|
|
2352
|
+
skipWhitespace()
|
|
2353
|
+
if (at < text.length && text[at] == ']') { at++; return true }
|
|
2354
|
+
while (true) {
|
|
2355
|
+
skipWhitespace()
|
|
2356
|
+
if (!value()) return false
|
|
2357
|
+
val done = afterMember(']') ?: return false
|
|
2358
|
+
if (done) return true
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
private fun string(): Boolean {
|
|
2363
|
+
at++
|
|
2364
|
+
var pendingHighSurrogate = false
|
|
2365
|
+
while (at < text.length) {
|
|
2366
|
+
val character = text[at]
|
|
2367
|
+
when {
|
|
2368
|
+
character == '"' -> { at++; return !pendingHighSurrogate }
|
|
2369
|
+
character == '\\' -> {
|
|
2370
|
+
at++
|
|
2371
|
+
if (at >= text.length) return false
|
|
2372
|
+
val escape = text[at]
|
|
2373
|
+
if (escape == 'u') {
|
|
2374
|
+
if (at + 4 >= text.length) return false
|
|
2375
|
+
var code = 0
|
|
2376
|
+
for (offset in 1..4) {
|
|
2377
|
+
val digit = Character.digit(text[at + offset], 16)
|
|
2378
|
+
if (digit < 0) return false
|
|
2379
|
+
code = code * 16 + digit
|
|
2380
|
+
}
|
|
2381
|
+
at += 5
|
|
2382
|
+
val high = code in 0xD800..0xDBFF
|
|
2383
|
+
val low = code in 0xDC00..0xDFFF
|
|
2384
|
+
// A low surrogate is only legal directly after a high
|
|
2385
|
+
// one, and a high one must be followed by a low one.
|
|
2386
|
+
if (pendingHighSurrogate != low) return false
|
|
2387
|
+
pendingHighSurrogate = high
|
|
2388
|
+
continue
|
|
2389
|
+
}
|
|
2390
|
+
if (pendingHighSurrogate) return false
|
|
2391
|
+
if (escape !in listOf('"', '\\', '/', 'b', 'f', 'n', 'r', 't')) return false
|
|
2392
|
+
at++
|
|
2393
|
+
}
|
|
2394
|
+
character.code < 0x20 -> return false
|
|
2395
|
+
else -> {
|
|
2396
|
+
if (pendingHighSurrogate) return false
|
|
2397
|
+
at++
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
return false
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
private fun number(): Boolean {
|
|
2405
|
+
val start = at
|
|
2406
|
+
if (at < text.length && text[at] == '-') at++
|
|
2407
|
+
val integerStart = at
|
|
2408
|
+
while (at < text.length && text[at] in '0'..'9') at++
|
|
2409
|
+
if (at == integerStart) return false
|
|
2410
|
+
// A leading zero may not be followed by another digit.
|
|
2411
|
+
if (text[integerStart] == '0' && at - integerStart > 1) return false
|
|
2412
|
+
if (at < text.length && text[at] == '.') {
|
|
2413
|
+
at++
|
|
2414
|
+
val fractionStart = at
|
|
2415
|
+
while (at < text.length && text[at] in '0'..'9') at++
|
|
2416
|
+
if (at == fractionStart) return false
|
|
2417
|
+
}
|
|
2418
|
+
if (at < text.length && (text[at] == 'e' || text[at] == 'E')) {
|
|
2419
|
+
at++
|
|
2420
|
+
if (at < text.length && (text[at] == '+' || text[at] == '-')) at++
|
|
2421
|
+
val exponentStart = at
|
|
2422
|
+
while (at < text.length && text[at] in '0'..'9') at++
|
|
2423
|
+
if (at == exponentStart) return false
|
|
2424
|
+
}
|
|
2425
|
+
return at > start
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
/** Whether a snapshot payload is JSON both hosts accept. */
|
|
2430
|
+
private fun craftNativeIsJsonText(text: String): Boolean = CraftNativeJsonScanner(text).validate()
|
|
2431
|
+
|
|
@@ -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)
|
|
@@ -149,6 +149,27 @@ private fun craftNativeBoolean(value: Any?, fallback: Boolean = false): Boolean
|
|
|
149
149
|
|
|
150
150
|
private fun craftNativeInt(value: Any?, fallback: Int): Int = (value as? Number)?.toInt() ?: fallback
|
|
151
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Where a field's text and hint begin, vertically.
|
|
154
|
+
*
|
|
155
|
+
* The platform's `Widget.EditText` carries `gravity = center_vertical`, and
|
|
156
|
+
* nothing between it and this app overrides it: `Theme.CraftApp` inherits
|
|
157
|
+
* `Theme.Material3.DayNight.NoActionBar`, no Material3 theme binds
|
|
158
|
+
* `android:editTextStyle`, and AppCompat binds it to
|
|
159
|
+
* `Widget.AppCompat.EditText`, whose chain reaches `android:Widget.EditText`
|
|
160
|
+
* without touching gravity. A single-line field is never taller than its
|
|
161
|
+
* text, so the centring cannot show; a field given a `minHeight` drew its
|
|
162
|
+
* first line — and its hint — halfway down the box, where iOS and the web
|
|
163
|
+
* both start at the top (#502).
|
|
164
|
+
*
|
|
165
|
+
* Only the vertical bits are replaced. The horizontal ones are left exactly
|
|
166
|
+
* as they were, because `textAlign` resolves through `textAlignment`, whose
|
|
167
|
+
* default of `TEXT_ALIGNMENT_INHERIT` can still fall back to this gravity.
|
|
168
|
+
*/
|
|
169
|
+
private fun craftNativeVerticalGravity(current: Int, multiline: Boolean): Int =
|
|
170
|
+
(current and Gravity.VERTICAL_GRAVITY_MASK.inv()) or (if (multiline) Gravity.TOP else Gravity.CENTER_VERTICAL)
|
|
171
|
+
|
|
172
|
+
|
|
152
173
|
/**
|
|
153
174
|
* A scroll event's geometry in density-independent units.
|
|
154
175
|
*
|
|
@@ -6689,6 +6710,8 @@ class MainActivity : AppCompatActivity() {
|
|
|
6689
6710
|
if (isSingleLine == multiline) isSingleLine = !multiline
|
|
6690
6711
|
val wantedLines = if (multiline) craftNativeInt(props.opt("numberOfLines"), Int.MAX_VALUE).coerceAtLeast(1) else 1
|
|
6691
6712
|
if (maxLines != wantedLines) maxLines = wantedLines
|
|
6713
|
+
val wantedGravity = craftNativeVerticalGravity(gravity, multiline)
|
|
6714
|
+
if (gravity != wantedGravity) gravity = wantedGravity
|
|
6692
6715
|
isEnabled = craftNativeBoolean(props.opt("editable"), true)
|
|
6693
6716
|
configureTextStyle(this, style)
|
|
6694
6717
|
// The style's padding is where the text starts, as on iOS and
|
package/dist/cli.js
CHANGED
|
@@ -1025,6 +1025,9 @@ async function detachDiskImage(mount, options = {}) {
|
|
|
1025
1025
|
await wait(attempt * 2000);
|
|
1026
1026
|
}
|
|
1027
1027
|
}
|
|
1028
|
+
function attachedDevice(attachOutput) {
|
|
1029
|
+
return attachOutput.match(/^(\/dev\/disk\d+)(?=\s)/m)?.[1] ?? null;
|
|
1030
|
+
}
|
|
1028
1031
|
async function createDiskImage(opts) {
|
|
1029
1032
|
if (!/^[^/:\n]{1,27}$/.test(opts.volumeName))
|
|
1030
1033
|
return { success: false, error: `Invalid DMG volume name "${opts.volumeName}"; must be 1..27 chars without /, :, or newline` };
|
|
@@ -1068,7 +1071,7 @@ async function createDiskImage(opts) {
|
|
|
1068
1071
|
phase = "layout";
|
|
1069
1072
|
writeInstallerLayout(mount, opts.volumeName, bundleName);
|
|
1070
1073
|
phase = "detach";
|
|
1071
|
-
const detached = await detachDiskImage(mount);
|
|
1074
|
+
const detached = await detachDiskImage(attachedDevice(attachment) ?? mount);
|
|
1072
1075
|
if (!detached.success) {
|
|
1073
1076
|
detachAttempts = detached.attempts;
|
|
1074
1077
|
throw new Error(detached.error);
|
|
@@ -1083,7 +1086,7 @@ async function createDiskImage(opts) {
|
|
|
1083
1086
|
Disk image lifecycle: ${JSON.stringify({ phase, image: writable, mount, attachment, detachAttempts })}` };
|
|
1084
1087
|
} finally {
|
|
1085
1088
|
if (mounted)
|
|
1086
|
-
await runTool("hdiutil", ["detach", mount, "-force"]);
|
|
1089
|
+
await runTool("hdiutil", ["detach", attachedDevice(attachment) ?? mount, "-force"]);
|
|
1087
1090
|
removeTempDir(scratch);
|
|
1088
1091
|
}
|
|
1089
1092
|
}
|
|
@@ -3243,7 +3246,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3243
3246
|
`);
|
|
3244
3247
|
}
|
|
3245
3248
|
// package.json
|
|
3246
|
-
var version = "0.0.
|
|
3249
|
+
var version = "0.0.492";
|
|
3247
3250
|
|
|
3248
3251
|
// bin/cli.ts
|
|
3249
3252
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
|
@@ -376,7 +376,7 @@ function craftBinaryNotFoundMessage(triedPath) {
|
|
|
376
376
|
`);
|
|
377
377
|
}
|
|
378
378
|
// package.json
|
|
379
|
-
var version = "0.0.
|
|
379
|
+
var version = "0.0.492";
|
|
380
380
|
|
|
381
381
|
// src/sdk-version.ts
|
|
382
382
|
var SDK_VERSION = version;
|
|
@@ -1393,6 +1393,9 @@ async function detachDiskImage(mount, options = {}) {
|
|
|
1393
1393
|
await wait(attempt * 2000);
|
|
1394
1394
|
}
|
|
1395
1395
|
}
|
|
1396
|
+
function attachedDevice(attachOutput) {
|
|
1397
|
+
return attachOutput.match(/^(\/dev\/disk\d+)(?=\s)/m)?.[1] ?? null;
|
|
1398
|
+
}
|
|
1396
1399
|
async function createDiskImage(opts) {
|
|
1397
1400
|
if (!/^[^/:\n]{1,27}$/.test(opts.volumeName))
|
|
1398
1401
|
return { success: false, error: `Invalid DMG volume name "${opts.volumeName}"; must be 1..27 chars without /, :, or newline` };
|
|
@@ -1436,7 +1439,7 @@ async function createDiskImage(opts) {
|
|
|
1436
1439
|
phase = "layout";
|
|
1437
1440
|
writeInstallerLayout(mount, opts.volumeName, bundleName);
|
|
1438
1441
|
phase = "detach";
|
|
1439
|
-
const detached = await detachDiskImage(mount);
|
|
1442
|
+
const detached = await detachDiskImage(attachedDevice(attachment) ?? mount);
|
|
1440
1443
|
if (!detached.success) {
|
|
1441
1444
|
detachAttempts = detached.attempts;
|
|
1442
1445
|
throw new Error(detached.error);
|
|
@@ -1451,7 +1454,7 @@ async function createDiskImage(opts) {
|
|
|
1451
1454
|
Disk image lifecycle: ${JSON.stringify({ phase, image: writable, mount, attachment, detachAttempts })}` };
|
|
1452
1455
|
} finally {
|
|
1453
1456
|
if (mounted)
|
|
1454
|
-
await runTool("hdiutil", ["detach", mount, "-force"]);
|
|
1457
|
+
await runTool("hdiutil", ["detach", attachedDevice(attachment) ?? mount, "-force"]);
|
|
1455
1458
|
removeTempDir(scratch);
|
|
1456
1459
|
}
|
|
1457
1460
|
}
|