craft-native 0.0.497 → 0.0.499

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.
@@ -17,6 +17,34 @@ internal fun craftAppLinkPathMatches(pattern: String, path: String): Boolean {
17
17
 
18
18
  /** Ordered website path rules, also publishable as Android 15 dynamic App Links. */
19
19
  internal object CraftAppLinks {
20
+ /**
21
+ * A link as a screen or a page hears it.
22
+ *
23
+ * A link is whatever its sender wrote. One written without `//`
24
+ * (`myapp:open?x=1`) is what Android calls opaque: it has no path or
25
+ * query of its own, and asking for its parameters throws. Both hosts
26
+ * asked on every link, on the main thread, so the app closed on being
27
+ * handed one. Its path and query are read from what follows the scheme,
28
+ * which is how UIKit reads the same link. A parameter named twice gives
29
+ * its first value.
30
+ */
31
+ fun describe(uri: Uri): JSONObject {
32
+ val rest = if (uri.isOpaque) uri.encodedSchemeSpecificPart.orEmpty() else null
33
+ val asked = if (rest == null) uri else Uri.parse("craft://link/?" + rest.substringAfter('?', ""))
34
+ // A link with nothing after its `?`, or no `?`, has no parameters;
35
+ // asked anyway, Android answers one with an empty name.
36
+ val named = if (asked.encodedQuery.isNullOrEmpty()) emptySet() else asked.queryParameterNames
37
+ return JSONObject()
38
+ .put("url", uri.toString())
39
+ .put("scheme", uri.scheme ?: "")
40
+ .put("host", uri.host ?: "")
41
+ .put("path", if (rest == null) uri.path ?: "" else Uri.decode(rest.substringBefore('?')))
42
+ .put("query", asked.query ?: "")
43
+ .put("queryParams", JSONObject().apply {
44
+ named.forEach { name -> put(name, asked.getQueryParameter(name) ?: "") }
45
+ })
46
+ }
47
+
20
48
  fun opensInBrowser(config: JSONObject?, uri: Uri): Boolean {
21
49
  if (uri.scheme != "https") return false
22
50
  val hosts = config?.optJSONArray("appLinks") ?: return false
@@ -5351,20 +5351,8 @@ class CraftBridge(
5351
5351
 
5352
5352
  activity.runOnUiThread {
5353
5353
  if (initialURL != null) {
5354
- val uri = Uri.parse(initialURL)
5355
- val json = JSONObject().apply {
5356
- put("url", initialURL)
5357
- put("scheme", uri.scheme ?: "")
5358
- put("host", uri.host ?: "")
5359
- put("path", uri.path ?: "")
5360
- put("query", uri.query ?: "")
5361
- // Parse query parameters
5362
- val queryParams = JSONObject()
5363
- uri.queryParameterNames.forEach { name ->
5364
- queryParams.put(name, uri.getQueryParameter(name) ?: "")
5365
- }
5366
- put("queryParams", queryParams)
5367
- }
5354
+ // Described where every link is: a link without `//` threw here.
5355
+ val json = CraftAppLinks.describe(Uri.parse(initialURL)).put("url", initialURL)
5368
5356
  evaluatePromiseJavascript(
5369
5357
  "window._craftDeepLinkResolve && window._craftDeepLinkResolve($json)"
5370
5358
  )
@@ -5415,21 +5403,7 @@ class CraftBridge(
5415
5403
  private fun dispatchDeepLink(url: String, initial: Boolean) {
5416
5404
  if (nativeDeepLinks && CraftNative.dispatchDeepLink(url, initial)) return
5417
5405
 
5418
- val uri = Uri.parse(url)
5419
- val json = JSONObject().apply {
5420
- put("url", url)
5421
- put("scheme", uri.scheme ?: "")
5422
- put("host", uri.host ?: "")
5423
- put("path", uri.path ?: "")
5424
- put("query", uri.query ?: "")
5425
- // Parse query parameters
5426
- val queryParams = JSONObject()
5427
- uri.queryParameterNames.forEach { name ->
5428
- queryParams.put(name, uri.getQueryParameter(name) ?: "")
5429
- }
5430
- put("queryParams", queryParams)
5431
- put("initial", initial)
5432
- }
5406
+ val json = CraftAppLinks.describe(Uri.parse(url)).put("url", url).put("initial", initial)
5433
5407
 
5434
5408
  evaluateJavascriptUnlessClosed(
5435
5409
  "window.dispatchEvent(new CustomEvent('craftDeepLink', {detail: $json}));"
@@ -251,6 +251,23 @@ internal class CraftNativeHeadlessApp(
251
251
  private val timers = mutableMapOf<Int, Runnable>()
252
252
  private val chunks = CraftNativeChunks()
253
253
 
254
+ /**
255
+ * The clock's reading at this script's first frame, as a screen keeps
256
+ * (`Screen.frameTimeOrigin` in the activity).
257
+ *
258
+ * `requestAnimationFrame` hands its callback milliseconds from the
259
+ * script's own origin. This host handed it milliseconds from boot, so
260
+ * code shared with a screen read a first frame near zero there and one
261
+ * in the billions here, on the same device.
262
+ */
263
+ private var frameTimeOrigin: Long? = null
264
+
265
+ /** Milliseconds from this script's first frame, for a reading of the monotonic clock in nanoseconds. */
266
+ private fun frameTime(now: Long): Double {
267
+ val origin = frameTimeOrigin ?: now.also { frameTimeOrigin = it }
268
+ return (now - origin) / 1_000_000.0
269
+ }
270
+
254
271
  init {
255
272
  capabilities.storeObserver = { store, key, value -> stores.changed(store, key, value, scope) }
256
273
  services.onBackgroundComplete = { ok, answered -> if (answered == null || answered == id) finish(ok) }
@@ -273,7 +290,8 @@ internal class CraftNativeHeadlessApp(
273
290
  if (entry.message.startsWith(prefix)) chunks.accept(entry.message.removePrefix(prefix))?.let(::receive)
274
291
  else Log.i(TAG, "[background] ${entry.message}")
275
292
  })
276
- CraftNativeHub.register(scope) { type, payload -> send(type, payload) }
293
+ // Not able to hear yet: its prelude is built and run below.
294
+ CraftNativeHub.registerWaiting(scope) { type, payload -> send(type, payload) }
277
295
  val names = capabilities.names().apply { services.names().forEach { put(it) } }
278
296
  val bridgeFields = """
279
297
  platform: 'android',
@@ -300,6 +318,7 @@ internal class CraftNativeHeadlessApp(
300
318
  if (closed) return@addListener
301
319
  try {
302
320
  setup.get()
321
+ CraftNativeHub.ready(scope)
303
322
  val load = created.evaluateJavaScriptAsync(bundle)
304
323
  load.addListener({
305
324
  try {
@@ -349,7 +368,7 @@ internal class CraftNativeHeadlessApp(
349
368
  }
350
369
  "TIMER_CLEAR" -> timers.remove(payload.optInt("id"))?.let(main::removeCallbacks)
351
370
  // Nothing is drawn in the background: frames come at a calm pace.
352
- "FRAME" -> main.postDelayed({ send("FRAME_FIRE", JSONObject().put("time", android.os.SystemClock.uptimeMillis().toDouble())) }, 100)
371
+ "FRAME" -> main.postDelayed({ send("FRAME_FIRE", JSONObject().put("time", frameTime(System.nanoTime()))) }, 100)
353
372
  // A background refresh shows nothing: navigation waits for the app.
354
373
  }
355
374
  }
@@ -276,7 +276,10 @@ internal class CraftNativeCapabilities(
276
276
  complete(requestToken, failure("CAPABILITY_DISABLED", "Local database is disabled"), completion)
277
277
  }
278
278
  else databaseExecutor.execute {
279
- complete(requestToken, database(method, args), completion)
279
+ // Asked before the host closed and reached after: it
280
+ // must not reopen what closing is about to give back.
281
+ if (closed.get()) return@execute
282
+ complete(requestToken, database(requestToken, method, args), completion)
280
283
  }
281
284
  }
282
285
  "Lifecycle" -> complete(requestToken, if (method == "getState") success(activity?.nativeAppState ?: "background")
@@ -324,17 +327,42 @@ internal class CraftNativeCapabilities(
324
327
  runCatching { connectivity.unregisterNetworkCallback(networkWatch) }
325
328
  cancelled.clear()
326
329
  if (config.optBoolean("enableBluetooth")) bluetooth.disconnect()
327
- databaseExecutor.shutdownNow()
330
+ closeDatabase()
328
331
  networkExecutor.shutdownNow()
329
332
  fetchConnections.values.forEach(HttpURLConnection::disconnect)
330
333
  fetchConnections.clear()
331
334
  onMain { dismissFilePick(); pendingPedometerPermission = null; pedometerPermissionInFlight = false }
332
- database?.close()
333
- database = null
334
335
  biometricPrompts.values.forEach { it.cancelAuthentication() }
335
336
  biometricPrompts.clear()
336
337
  }
337
338
 
339
+ /**
340
+ * The database is closed on its own thread, and a transaction this host
341
+ * left open is rolled back there first.
342
+ *
343
+ * A transaction belongs to the thread that began it. Closing from the
344
+ * caller's thread could not end it, so its connection stayed open where
345
+ * nothing could reach it again, holding the write lock on the file. Each
346
+ * host has its own connection to that one file: the next activity, or
347
+ * the script started for a background refresh, answered every call with
348
+ * "database is locked" until the lost connection happened to be
349
+ * collected. Calls still queued behind this one are answering a closed
350
+ * host and do nothing.
351
+ */
352
+ private fun closeDatabase() {
353
+ runCatching {
354
+ databaseExecutor.execute {
355
+ val db = database ?: return@execute
356
+ database = null
357
+ transactionScope = null
358
+ runCatching { if (db.inTransaction()) db.endTransaction() }
359
+ .onFailure { android.util.Log.w("CraftNativeAndroid", "A closed host's transaction could not be rolled back", it) }
360
+ runCatching { db.close() }
361
+ }
362
+ }
363
+ databaseExecutor.shutdown()
364
+ }
365
+
338
366
  fun pedometerPermissionGranted(): Boolean = checkPedometerPermission?.invoke() ?: (Build.VERSION.SDK_INT < 29 ||
339
367
  ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) == PackageManager.PERMISSION_GRANTED)
340
368
 
@@ -535,18 +563,7 @@ internal class CraftNativeCapabilities(
535
563
 
536
564
  private fun files(requestToken: String, method: String, args: JSONArray): CraftNativeCapabilityResult {
537
565
  return when (method) {
538
- "write" -> {
539
- val name = args.optString(0).trim()
540
- if (name.isBlank()) return failure("INVALID_ARGUMENT", "files.write needs a file name")
541
- val data = args.optString(1, "")
542
- val options = args.optJSONObject(2) ?: JSONObject()
543
- val bytes = when (options.optString("encoding", "utf8")) {
544
- "utf8" -> data.toByteArray(Charsets.UTF_8)
545
- "base64" -> decodeBase64(data) ?: return failure("INVALID_ARGUMENT", "files.write base64 is invalid")
546
- else -> return failure("INVALID_ARGUMENT", "files.write encoding is 'utf8' or 'base64'")
547
- }
548
- store(bytes, name, options.optString("type").ifBlank { URLConnection.guessContentTypeFromName(name) ?: "application/octet-stream" })
549
- }
566
+ "write" -> write(args)
550
567
  "remove" -> {
551
568
  val file = ownedFile(args.optString(0)) ?: return failure("INVALID_ARGUMENT", "files.remove needs a file made by craft.files")
552
569
  success(removeOwnedFile(file))
@@ -556,6 +573,30 @@ internal class CraftNativeCapabilities(
556
573
  }
557
574
  }
558
575
 
576
+ /**
577
+ * What a screen made itself, written as a file: text, or bytes as base64.
578
+ *
579
+ * The name and the contents are strings, and nothing else is read as
580
+ * one. `optString` reads anything: a number became its digits, `null`
581
+ * the four letters "null", and bytes handed over as they were (a
582
+ * `Uint8Array` crosses the bridge as `{"0":1,"1":2}`) became that JSON
583
+ * text, written to a file and answered as a success. UIKit refuses each
584
+ * of those, and so does this. An absent or `null` encoding is `utf8`.
585
+ */
586
+ private fun write(args: JSONArray): CraftNativeCapabilityResult {
587
+ val name = (args.opt(0) as? String)?.trim().orEmpty()
588
+ if (name.isBlank()) return failure("INVALID_ARGUMENT", "files.write needs a file name")
589
+ val data = args.opt(1) as? String ?: return failure("INVALID_ARGUMENT", "files.write needs its contents as a string")
590
+ val options = args.optJSONObject(2) ?: JSONObject()
591
+ val encoding = if (options.isNull("encoding")) "utf8" else options.opt("encoding") as? String
592
+ val bytes = when (encoding) {
593
+ "utf8" -> data.toByteArray(Charsets.UTF_8)
594
+ "base64" -> decodeBase64(data) ?: return failure("INVALID_ARGUMENT", "files.write base64 is invalid")
595
+ else -> return failure("INVALID_ARGUMENT", "files.write encoding is 'utf8' or 'base64'")
596
+ }
597
+ return store(bytes, name, options.optString("type").ifBlank { URLConnection.guessContentTypeFromName(name) ?: "application/octet-stream" })
598
+ }
599
+
559
600
  /**
560
601
  * Base64 as `atob` reads it, which is what the browser host runs and what
561
602
  * UIKit is held to: the alphabet, ASCII white space anywhere, padding
@@ -1172,7 +1213,12 @@ internal class CraftNativeCapabilities(
1172
1213
  "compareAndSet" -> {
1173
1214
  if (args.length() != 3) return failure("INVALID_ARGUMENT", "Storage.compareAndSet needs a key, expected value and next value")
1174
1215
  val current = storage.getString(key, null) ?: return success(false)
1175
- if (!CraftNativeStorage.jsonEqual(JSONTokener(current).nextValue(), args.opt(1))) return success(false)
1216
+ // What `get` answers for a value it cannot read. Unparsed, it
1217
+ // threw from here and reached the screen as STORAGE_ERROR,
1218
+ // the code for a disk that failed.
1219
+ val held = runCatching { JSONTokener(current).nextValue() }
1220
+ .getOrElse { return failure("STORAGE_CORRUPT", "Stored value is not valid JSON") }
1221
+ if (!CraftNativeStorage.jsonEqual(held, args.opt(1))) return success(false)
1176
1222
  val next = jsonString(args.opt(2)) ?: return failure("INVALID_ARGUMENT", "Storage values must be JSON serializable")
1177
1223
  if (!CraftNativeStorage.commit(storage) { it.putString(key, next) }) return failure("STORAGE_ERROR", "Storage could not persist the checkpoint")
1178
1224
  storeObserver?.invoke("storage", key, next)
@@ -1292,7 +1338,50 @@ internal class CraftNativeCapabilities(
1292
1338
  }
1293
1339
  }
1294
1340
 
1295
- private fun database(method: String, args: JSONArray): CraftNativeCapabilityResult {
1341
+ /**
1342
+ * A screen has closed. A transaction it began and never finished is
1343
+ * rolled back.
1344
+ *
1345
+ * The database is one connection shared by every screen, and a
1346
+ * transaction left open on it does not end with the screen that began
1347
+ * it: every later write joins it, reads back as written, and is gone
1348
+ * when the process exits, since nobody is left to commit. Rolling back
1349
+ * loses what the closed screen had not committed, which is what closing
1350
+ * mid-transaction means, and gives every later write its own fate back.
1351
+ * Queued behind whatever that screen had already asked for.
1352
+ */
1353
+ fun endScope(scope: String) {
1354
+ if (closed.get()) return
1355
+ runCatching {
1356
+ databaseExecutor.execute {
1357
+ if (transactionScope != scope) return@execute
1358
+ transactionScope = null
1359
+ val db = database ?: return@execute
1360
+ runCatching { if (db.inTransaction()) db.endTransaction() }
1361
+ .onFailure { android.util.Log.w("CraftNativeAndroid", "A closed screen's transaction could not be rolled back", it) }
1362
+ }
1363
+ }
1364
+ }
1365
+
1366
+ /**
1367
+ * Which screen's transaction is open, read off the database around each
1368
+ * call rather than off the method's name: a call that leaves a
1369
+ * transaction open where there was none began it, and one that leaves
1370
+ * none ended it. The scope is the part of a request token before its
1371
+ * last `/`. Only touched on the database thread.
1372
+ */
1373
+ private var transactionScope: String? = null
1374
+
1375
+ private fun database(requestToken: String, method: String, args: JSONArray): CraftNativeCapabilityResult {
1376
+ val before = runCatching { database?.inTransaction() == true }.getOrDefault(false)
1377
+ val answer = databaseCall(method, args)
1378
+ val after = runCatching { database?.inTransaction() == true }.getOrDefault(false)
1379
+ if (after && !before) transactionScope = requestToken.substringBeforeLast('/', "")
1380
+ else if (!after) transactionScope = null
1381
+ return answer
1382
+ }
1383
+
1384
+ private fun databaseCall(method: String, args: JSONArray): CraftNativeCapabilityResult {
1296
1385
  val db = database ?: runCatching {
1297
1386
  context.openOrCreateDatabase("craft-native.sqlite", Context.MODE_PRIVATE, null)
1298
1387
  }.getOrElse { return failure("DATABASE_ERROR", it.message ?: "Could not open the local database") }
@@ -1309,7 +1398,12 @@ internal class CraftNativeCapabilities(
1309
1398
  if (sql.isEmpty()) return failure("INVALID_ARGUMENT", "Database.query needs a SQL statement")
1310
1399
  query(db, sql, args.optJSONArray(1) ?: JSONArray())
1311
1400
  }
1312
- "beginTransaction" -> { db.beginTransaction(); success(true) }
1401
+ // SQLite refuses a transaction inside a transaction, and that
1402
+ // is UIKit's answer. Android would nest it instead: the inner
1403
+ // `commit` then makes nothing durable and reports that it did,
1404
+ // and the outer one is some other caller's to finish.
1405
+ "beginTransaction" -> if (db.inTransaction()) failure("DATABASE_ERROR", "cannot start a transaction within a transaction")
1406
+ else { db.beginTransaction(); success(true) }
1313
1407
  "commit" -> {
1314
1408
  if (!db.inTransaction()) failure("DATABASE_ERROR", "No transaction is active")
1315
1409
  else { db.setTransactionSuccessful(); db.endTransaction(); success(true) }
@@ -1435,13 +1529,10 @@ internal class CraftNativeCapabilities(
1435
1529
  "cancel" -> {
1436
1530
  val id = validKey(args.opt(0))
1437
1531
  complete(requestToken, if (id == null) failure("INVALID_ARGUMENT", "Notifications.cancel needs an id")
1438
- else { cancelNotification(id); success(true) }, completion)
1439
- }
1440
- "cancelAll" -> {
1441
- notificationStorage.all.keys.forEach(::cancelNotification)
1442
- CraftNativeNotifications.cancelAll(context)
1443
- complete(requestToken, success(true), completion)
1532
+ else if (cancelNotification(id)) success(true)
1533
+ else failure("NOTIFICATION_ERROR", "The notification could not be cancelled"), completion)
1444
1534
  }
1535
+ "cancelAll" -> complete(requestToken, cancelEveryNotification(), completion)
1445
1536
  "pending" -> complete(requestToken, success(JSONArray(notificationStorage.all.values.mapNotNull {
1446
1537
  runCatching { JSONObject(it as String) }.getOrNull()
1447
1538
  })), completion)
@@ -1467,7 +1558,13 @@ internal class CraftNativeCapabilities(
1467
1558
  val id = validKey(value?.opt("id")) ?: UUID.randomUUID().toString()
1468
1559
  val fireAt = System.currentTimeMillis() + value!!.optLong("delay", 0).coerceAtLeast(0)
1469
1560
  val stored = JSONObject(value.toString()).put("id", id).put("fireAt", fireAt)
1470
- notificationStorage.edit().putString(id, stored.toString()).commit()
1561
+ // What is stored under the id is what the receiver shows when the
1562
+ // alarm fires, usually in a process started for it. Scheduled without
1563
+ // being saved, the alarm fired, found nothing and showed nothing,
1564
+ // after this had answered with the id.
1565
+ if (!CraftNativeStorage.commit(notificationStorage) { it.putString(id, stored.toString()) }) {
1566
+ return failure("NOTIFICATION_ERROR", "The notification could not be saved")
1567
+ }
1471
1568
  scheduleStored(stored)
1472
1569
  return success(id)
1473
1570
  }
@@ -1489,14 +1586,34 @@ internal class CraftNativeCapabilities(
1489
1586
  )
1490
1587
  }
1491
1588
 
1492
- private fun cancelNotification(id: String) {
1589
+ /**
1590
+ * Cancels a scheduled notification. False when its removal did not reach
1591
+ * the disk, and then nothing is cancelled.
1592
+ *
1593
+ * Every start schedules whatever is stored (`restoreNotifications`). The
1594
+ * alarm used to be cancelled first and the failed removal ignored, so a
1595
+ * reminder looked cancelled for the life of the process and was
1596
+ * scheduled again by the next one. It is removed from storage first, and
1597
+ * one that cannot be removed stays as it was, alarm included, and the
1598
+ * call says so.
1599
+ */
1600
+ private fun cancelNotification(id: String): Boolean {
1601
+ if (!CraftNativeStorage.commit(notificationStorage) { it.remove(id) }) return false
1493
1602
  CraftNativeNotifications.cancel(context, id)
1494
1603
  val alarm = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
1495
1604
  val intent = notificationIntent(id)
1496
1605
  alarm.cancel(intent)
1497
1606
  intent.cancel()
1498
- notificationStorage.edit().remove(id).commit()
1499
1607
  (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).cancel(id, 0)
1608
+ return true
1609
+ }
1610
+
1611
+ /** `cancelAll`: every scheduled notification, and an error naming how many could not be. */
1612
+ private fun cancelEveryNotification(): CraftNativeCapabilityResult {
1613
+ val left = notificationStorage.all.keys.toList().filterNot(::cancelNotification)
1614
+ CraftNativeNotifications.cancelAll(context)
1615
+ return if (left.isEmpty()) success(true)
1616
+ else failure("NOTIFICATION_ERROR", "${left.size} scheduled notifications could not be cancelled")
1500
1617
  }
1501
1618
 
1502
1619
  private fun notificationIntent(id: String): PendingIntent = PendingIntent.getBroadcast(
@@ -58,12 +58,56 @@ internal object CraftNativeHub {
58
58
  private val receivers = ConcurrentHashMap<String, (String, JSONObject) -> Unit>()
59
59
  private val main = Handler(Looper.getMainLooper())
60
60
 
61
+ /** What was broadcast to an isolate that could not hear yet, in order. Guarded by itself. */
62
+ private val waiting = HashMap<String, ArrayList<Pair<String, JSONObject>>>()
63
+
61
64
  fun register(id: String, receiver: (String, JSONObject) -> Unit) {
62
65
  receivers[id] = receiver
63
66
  }
64
67
 
68
+ /**
69
+ * Registers an isolate whose prelude has not run yet.
70
+ *
71
+ * Such an isolate is registered before it is given its copy of the
72
+ * stores, and the copy is taken some time later, off the main thread. A
73
+ * change broadcast in between has nowhere to land: the prelude defines
74
+ * what receives it, so a message sent before the prelude runs is
75
+ * discarded, and the isolate goes on to start from a copy that may
76
+ * predate the change (#507). What is broadcast meanwhile is kept instead
77
+ * and handed over, in order, by [ready]. Changes are broadcast in the
78
+ * order they are made, so replaying them over a copy taken part-way
79
+ * through ends on the latest value.
80
+ */
81
+ fun registerWaiting(id: String, receiver: (String, JSONObject) -> Unit) {
82
+ synchronized(waiting) { waiting[id] = ArrayList() }
83
+ receivers[id] = { type, payload ->
84
+ val kept = synchronized(waiting) { waiting[id]?.also { it.add(type to payload) } }
85
+ if (kept == null) receiver(type, payload)
86
+ }
87
+ }
88
+
89
+ /**
90
+ * The isolate's prelude has run: it is given what was kept for it, then
91
+ * hears each broadcast as it is made. On the main thread, where
92
+ * broadcasts are delivered, so nothing can arrive between the two.
93
+ */
94
+ fun ready(id: String) {
95
+ val deliver = {
96
+ val kept = synchronized(waiting) { waiting.remove(id) }
97
+ val receiver = receivers[id]
98
+ if (kept != null && receiver != null) {
99
+ for ((type, payload) in kept) {
100
+ runCatching { receiver(type, payload) }
101
+ .onFailure { Log.w("CraftNativeAndroid", "Kept $type to $id failed", it) }
102
+ }
103
+ }
104
+ }
105
+ if (Looper.myLooper() == Looper.getMainLooper()) deliver() else main.post(deliver)
106
+ }
107
+
65
108
  fun unregister(id: String) {
66
109
  receivers.remove(id)
110
+ synchronized(waiting) { waiting.remove(id) }
67
111
  }
68
112
 
69
113
  /** Sends to every registered isolate but `except`, on the main thread. */
@@ -1015,6 +1059,8 @@ internal class CraftNativeChrome(
1015
1059
  val path = item.optString("path")
1016
1060
  return visiblePath == path || visiblePath?.startsWith(path + "/") == true
1017
1061
  }
1062
+ /** Drawn in the accent: its destination is showing, or the app says it is on. */
1063
+ private fun isAccessoryActive(item: JSONObject): Boolean = item.optBoolean("selected") || isAccessorySelected(item)
1018
1064
  private var swipeTracking = false
1019
1065
  private var swipeIntercepted = false
1020
1066
  private var horizontalScrollDeferred = false
@@ -1414,10 +1460,10 @@ internal class CraftNativeChrome(
1414
1460
  val grouped = accessoryGroup.parent === body
1415
1461
  accessoryGroup.background = if (grouped) GradientDrawable().apply { setColor(surface); cornerRadius = dp(26).toFloat() } else null
1416
1462
  tabBar.background = GradientDrawable().apply { setColor(surface); cornerRadius = dp(32).toFloat() }
1417
- val primarySelected = accessorySpec?.let { isAccessorySelected(it) } == true
1463
+ val primarySelected = accessorySpec?.let { isAccessoryActive(it) } == true
1418
1464
  accessory.isSelected = primarySelected
1419
1465
  companionViews.forEach { (action, item) ->
1420
- val selected = isAccessorySelected(item)
1466
+ val selected = isAccessoryActive(item)
1421
1467
  action.isSelected = selected
1422
1468
  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() }
1423
1469
  (action.getChildAt(0) as ImageView).setImageDrawable(CraftNativeIcons.drawable(context, null, item.optString("symbol"), if (selected) accent else text))
@@ -187,17 +187,36 @@ internal class CraftNativeSounds(
187
187
  return true
188
188
  }
189
189
 
190
+ /**
191
+ * The fade a `stop` asked for, in milliseconds: a JSON number that is
192
+ * finite and above zero, and otherwise none.
193
+ *
194
+ * `optDouble` also read "500" as a number, where UIKit reads only a
195
+ * number as one.
196
+ */
197
+ fun fadeOut(options: JSONObject?): Double {
198
+ val asked = (options?.opt("fadeOut") as? Number)?.toDouble() ?: return 0.0
199
+ return if (asked.isFinite() && asked > 0) asked else 0.0
200
+ }
201
+
202
+ /**
203
+ * Stops the sound, fading it out over `fadeOut` milliseconds first.
204
+ *
205
+ * Stopping is what was asked, and the fade is how. A `fadeOut` that is
206
+ * not a duration, a negative one say, used to be refused: the call
207
+ * answered `false`, which is the answer for a sound that does not exist,
208
+ * and the sound went on playing. UIKit and the browser host stop it at
209
+ * once, and so does this.
210
+ */
190
211
  fun stop(id: String, fadeOut: Double): Boolean {
191
212
  val sound = sounds[id] ?: return false
192
- if (!fadeOut.isFinite() || fadeOut < 0) return false
193
- if (fadeOut > 0 && !sound.paused) {
213
+ if (fadeOut.isFinite() && fadeOut > 0 && !sound.paused) {
194
214
  ramp(sound, 0f, fadeOut / 1000.0) { if (sounds[id] === sound) remove(id) }
195
215
  } else remove(id)
196
216
  return true
197
217
  }
198
218
 
199
219
  fun stopAll(fadeOut: Double): Boolean {
200
- if (!fadeOut.isFinite() || fadeOut < 0) return false
201
220
  sounds.keys.toList().forEach { stop(it, fadeOut) }
202
221
  return true
203
222
  }
@@ -5635,7 +5635,8 @@ class MainActivity : AppCompatActivity() {
5635
5635
  if (activeScreen.active) send(activeScreen, "SOUND_EVENT", payload)
5636
5636
  }
5637
5637
  screen = activeScreen
5638
- CraftNativeHub.register(activeScreen.capabilityScope) { type, payload ->
5638
+ // Not able to hear yet: its prelude is built and run below.
5639
+ CraftNativeHub.registerWaiting(activeScreen.capabilityScope) { type, payload ->
5639
5640
  if (activeScreen.active) send(activeScreen, type, payload)
5640
5641
  }
5641
5642
  if (!headless) {
@@ -5845,6 +5846,9 @@ class MainActivity : AppCompatActivity() {
5845
5846
  if (!activeScreen.active) return@addListener
5846
5847
  try {
5847
5848
  setup.get()
5849
+ // The prelude has run, so the isolate can hear: what changed
5850
+ // while it was being built, before the bundle reads any of it.
5851
+ CraftNativeHub.ready(activeScreen.capabilityScope)
5848
5852
  val source = CraftNativeBundle.source(this, route)
5849
5853
  if (source == null) {
5850
5854
  if (activeScreen.active) showError("Missing native-screen.js. Build with --native-bundle after compiling STX.")
@@ -5891,6 +5895,8 @@ class MainActivity : AppCompatActivity() {
5891
5895
  }
5892
5896
  }, executor)
5893
5897
  } catch (error: Exception) {
5898
+ // Nothing will run in this isolate, so nothing is kept for it.
5899
+ CraftNativeHub.ready(activeScreen.capabilityScope)
5894
5900
  Log.e(TAG, "Native STX bridge failed", error)
5895
5901
  showError("Native STX bridge failed: ${error.message}")
5896
5902
  }
@@ -8723,9 +8729,13 @@ class MainActivity : AppCompatActivity() {
8723
8729
  }
8724
8730
  "pause" -> completion(CraftNativeCapabilityResult(data = screen.sounds.pause(id)))
8725
8731
  "resume" -> completion(CraftNativeCapabilityResult(data = screen.sounds.resume(id)))
8726
- "setVolume" -> completion(CraftNativeCapabilityResult(data = screen.sounds.setVolume(id, args.optDouble(1, Double.NaN), args.optJSONObject(2)?.optDouble("fade", 0.0) ?: 0.0)))
8727
- "stop" -> completion(CraftNativeCapabilityResult(data = screen.sounds.stop(id, args.optJSONObject(1)?.optDouble("fadeOut", 0.0) ?: 0.0)))
8728
- "stopAll" -> completion(CraftNativeCapabilityResult(data = screen.sounds.stopAll(args.optJSONObject(0)?.optDouble("fadeOut", 0.0) ?: 0.0)))
8732
+ // A number is a JSON number: `optDouble` also read "0.5" as one,
8733
+ // where UIKit answers false for a volume given as text.
8734
+ "setVolume" -> completion(CraftNativeCapabilityResult(data = screen.sounds.setVolume(id,
8735
+ (args.opt(1) as? Number)?.toDouble() ?: Double.NaN,
8736
+ (args.optJSONObject(2)?.opt("fade") as? Number)?.toDouble() ?: 0.0)))
8737
+ "stop" -> completion(CraftNativeCapabilityResult(data = screen.sounds.stop(id, screen.sounds.fadeOut(args.optJSONObject(1)))))
8738
+ "stopAll" -> completion(CraftNativeCapabilityResult(data = screen.sounds.stopAll(screen.sounds.fadeOut(args.optJSONObject(0)))))
8729
8739
  else -> completion(CraftNativeCapabilityResult(code = "UNKNOWN_ACTION", message = "Unsupported Audio method"))
8730
8740
  }
8731
8741
  }
@@ -8865,15 +8875,8 @@ class MainActivity : AppCompatActivity() {
8865
8875
  else -> CraftNativeCapabilityResult(code = "UNKNOWN_ACTION", message = "Unsupported native API")
8866
8876
  }
8867
8877
 
8868
- internal fun deepLinkData(uri: android.net.Uri): JSONObject = JSONObject()
8869
- .put("url", uri.toString())
8870
- .put("scheme", uri.scheme ?: "")
8871
- .put("host", uri.host ?: "")
8872
- .put("path", uri.path ?: "")
8873
- .put("query", uri.query ?: "")
8874
- .put("queryParams", JSONObject().apply {
8875
- uri.queryParameterNames.forEach { name -> put(name, uri.getQueryParameter(name) ?: "") }
8876
- })
8878
+ /** A link as the screens hear it: `CraftAppLinks.describe`, which the page bridge uses too. */
8879
+ internal fun deepLinkData(uri: android.net.Uri): JSONObject = CraftAppLinks.describe(uri)
8877
8880
 
8878
8881
  /** App-wide news (the app's state, a link): every screen and the app script hear it. */
8879
8882
  private fun publish(type: String, payload: JSONObject) {
@@ -9423,6 +9426,7 @@ class MainActivity : AppCompatActivity() {
9423
9426
  screen.capabilityTimeouts.clear()
9424
9427
  screen.pendingCapabilities.forEach(capabilities::cancel)
9425
9428
  screen.pendingCapabilities.clear()
9429
+ capabilities.endScope(screen.capabilityScope)
9426
9430
  releaseViewState(screen, retainedViews(screen).toList())
9427
9431
  screen.inputWatchers.forEach { (input, watcher) -> input.removeTextChangedListener(watcher) }
9428
9432
  screen.inputWatchers.clear()
@@ -9463,6 +9467,8 @@ class MainActivity : AppCompatActivity() {
9463
9467
  screen.capabilityTimeouts.clear()
9464
9468
  screen.pendingCapabilities.forEach(capabilities::cancel)
9465
9469
  screen.pendingCapabilities.clear()
9470
+ // Again: a transaction can be begun while the isolate is closing.
9471
+ capabilities.endScope(screen.capabilityScope)
9466
9472
  done()
9467
9473
  }
9468
9474
  }
package/dist/browser.js CHANGED
@@ -909,8 +909,24 @@ async function createBrowserPlatform(options = {}) {
909
909
  }
910
910
  if (method === "write") {
911
911
  const settings = object(args[2]);
912
- const contents = settings.encoding === "base64" ? Uint8Array.from(atob(String(args[1])), (letter) => letter.charCodeAt(0)) : String(args[1]);
913
- return file([contents], String(args[0]), String(settings.type ?? "application/octet-stream"));
912
+ const [name, data] = args;
913
+ const refuse = (message) => Object.assign(new Error(message), { code: "INVALID_ARGUMENT" });
914
+ if (typeof name !== "string" || !name.trim())
915
+ throw refuse("files.write needs a file name");
916
+ if (typeof data !== "string")
917
+ throw refuse("files.write needs its contents as a string");
918
+ const encoding = settings.encoding ?? "utf8";
919
+ if (encoding !== "utf8" && encoding !== "base64")
920
+ throw refuse("files.write encoding is 'utf8' or 'base64'");
921
+ let contents = data;
922
+ if (encoding === "base64") {
923
+ try {
924
+ contents = Uint8Array.from(atob(data), (letter) => letter.charCodeAt(0));
925
+ } catch {
926
+ throw refuse("files.write base64 is invalid");
927
+ }
928
+ }
929
+ return file([contents], name, String(settings.type ?? "application/octet-stream"));
914
930
  }
915
931
  if (method === "remove") {
916
932
  const path = String(args[0]);
@@ -1071,14 +1087,15 @@ async function createBrowserPlatform(options = {}) {
1071
1087
  throw error;
1072
1088
  }
1073
1089
  }
1090
+ const fadeOut = (options) => typeof options.fadeOut === "number" ? options.fadeOut : 0;
1074
1091
  if (method === "stopAll") {
1075
1092
  soundRequests.clear();
1076
- await Promise.all([...sounds.keys()].map((key) => stopSound(key, Number(first.fadeOut ?? 0))));
1093
+ await Promise.all([...sounds.keys()].map((key) => stopSound(key, fadeOut(first))));
1077
1094
  return true;
1078
1095
  }
1079
1096
  if (method === "stop") {
1080
1097
  soundRequests.delete(id);
1081
- await stopSound(id, Number(object(args[1]).fadeOut ?? 0));
1098
+ await stopSound(id, fadeOut(object(args[1])));
1082
1099
  return Boolean(sound);
1083
1100
  }
1084
1101
  if (!sound)
@@ -1092,7 +1109,12 @@ async function createBrowserPlatform(options = {}) {
1092
1109
  return true;
1093
1110
  }
1094
1111
  if (method === "setVolume") {
1095
- await fade(sound, Number(args[1]), Number(object(args[2]).fade ?? 0));
1112
+ const volume = args[1];
1113
+ const asked = object(args[2]).fade;
1114
+ const milliseconds = typeof asked === "number" ? asked : 0;
1115
+ if (typeof volume !== "number" || !Number.isFinite(volume) || !Number.isFinite(milliseconds) || milliseconds < 0)
1116
+ return false;
1117
+ await fade(sound, volume, milliseconds);
1096
1118
  return true;
1097
1119
  }
1098
1120
  }
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.497";
3249
+ var version = "0.0.499";
3250
3250
 
3251
3251
  // bin/cli.ts
3252
3252
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -376,7 +376,7 @@ function craftBinaryNotFoundMessage(triedPath) {
376
376
  `);
377
377
  }
378
378
  // package.json
379
- var version = "0.0.497";
379
+ var version = "0.0.499";
380
380
 
381
381
  // src/sdk-version.ts
382
382
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.497";
120
+ var version = "0.0.499";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -822,6 +822,9 @@ final class CraftChrome: ObservableObject {
822
822
  /// A shared material capsule; nil keeps separate floating buttons.
823
823
  var accessoryLayout: String? = nil
824
824
  var companions: [Tab] = []
825
+ /// A floating action that is on: a mode it switched (tracking in
826
+ /// Maps), drawn in the tint whatever screen is showing.
827
+ var active = false
825
828
  /// A tap opens these destinations in a compact menu instead of navigating.
826
829
  var menu: [TabMenuItem] = []
827
830
  }
@@ -846,6 +849,8 @@ final class CraftChrome: ObservableObject {
846
849
  guard let path = visiblePath else { return false }
847
850
  return path == accessory.id || path.hasPrefix(accessory.id + "/")
848
851
  }
852
+ /// Drawn in the tint: its destination is showing, or the app says it is on.
853
+ func accessoryActive(_ accessory: Tab) -> Bool { accessory.active || accessorySelected(accessory) }
849
854
  var highlightedTab: String? { accessorySelected ? nil : selected }
850
855
  let tabSwipeProgress = CraftTabSwipeProgress()
851
856
  private(set) var tabSwipePosition: CGFloat? {
@@ -977,13 +982,15 @@ final class CraftChrome: ObservableObject {
977
982
  let companionTitle = action["title"] as? String else { return nil }
978
983
  return Tab(id: companionPath, title: companionTitle, symbol: action["symbol"] as? String ?? "circle",
979
984
  accessibilityLabel: action["accessibilityLabel"] as? String, iconOnly: true,
985
+ active: action["selected"] as? Bool ?? false,
980
986
  menu: ((action["menu"] as? [[String: Any]]) ?? []).prefix(12).compactMap { choice in
981
987
  guard let destination = choice["path"] as? String,
982
988
  destination.hasPrefix("/"), !destination.hasPrefix("//"),
983
989
  let label = choice["title"] as? String, !label.isEmpty else { return nil }
984
990
  return TabMenuItem(id: destination, title: label, symbol: choice["symbol"] as? String ?? "circle")
985
991
  })
986
- })
992
+ },
993
+ active: item["selected"] as? Bool ?? false)
987
994
  } else {
988
995
  next = nil
989
996
  }
@@ -1483,12 +1483,10 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1483
1483
  let button = UIButton(type: .system)
1484
1484
  var configuration = UIButton.Configuration.plain()
1485
1485
  configuration.image = UIImage(systemName: action.symbol)
1486
- configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
1487
1486
  button.configuration = configuration
1488
1487
  button.accessibilityIdentifier = "craft-tab-companion:" + action.id
1489
1488
  button.accessibilityLabel = action.accessibilityLabel ?? action.title
1490
1489
  button.accessibilityValue = action.badge
1491
- button.accessibilityTraits = chrome.accessorySelected(action) ? [.button, .selected] : [.button]
1492
1490
  if action.menu.isEmpty {
1493
1491
  button.addAction(UIAction { [weak self] _ in self?.hybrid.open(action.id) }, for: .touchUpInside)
1494
1492
  } else {
@@ -1512,9 +1510,6 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1512
1510
  companionButtons.append(button)
1513
1511
  }
1514
1512
  }
1515
- for (index, action) in companionModels.enumerated() {
1516
- companionButtons[index].accessibilityTraits = chrome.accessorySelected(action) ? [.button, .selected] : [.button]
1517
- }
1518
1513
  let grouped = model?.accessoryLayout != nil
1519
1514
  // A group is one control, as the map controls in Maps are: its
1520
1515
  // actions live inside the shared surface, so the glass lights the
@@ -1522,16 +1517,12 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1522
1517
  // Separate actions stay in the shell, each on its own surface.
1523
1518
  let actionHost: UIView = grouped ? accessoryGlass.contentView : view
1524
1519
  for glass in companionGlasses { glass.isHidden = grouped }
1525
- for button in companionButtons {
1520
+ for (index, button) in companionButtons.enumerated() {
1526
1521
  if button.superview !== actionHost { actionHost.addSubview(button) }
1527
- button.tintColor = grouped ? .label : nil
1528
- button.configuration?.preferredSymbolConfigurationForImage = Self.accessorySymbol(grouped: grouped)
1522
+ styleAccessory(button, for: companionModels[index], grouped: grouped)
1529
1523
  }
1530
- accessoryButton.tintColor = grouped ? .label : nil
1531
1524
  if let model {
1532
1525
  var configuration = UIButton.Configuration.plain()
1533
- configuration.image = UIImage(systemName: model.symbol)
1534
- configuration.preferredSymbolConfigurationForImage = Self.accessorySymbol(grouped: grouped)
1535
1526
  configuration.title = model.iconOnly ? nil : model.displayTitle
1536
1527
  configuration.subtitle = model.iconOnly ? nil : model.badge
1537
1528
  configuration.imagePadding = model.iconOnly ? 0 : 8
@@ -1539,7 +1530,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1539
1530
  button.configuration = configuration
1540
1531
  button.accessibilityLabel = model.accessibilityLabel ?? model.displayTitle
1541
1532
  button.accessibilityValue = model.badge
1542
- button.accessibilityTraits = chrome.accessorySelected(model) ? [.button, .selected] : [.button]
1533
+ styleAccessory(button, for: model, grouped: grouped)
1543
1534
  }
1544
1535
  }
1545
1536
  #if compiler(>=6.2)
@@ -1597,10 +1588,32 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1597
1588
  view.setNeedsLayout()
1598
1589
  }
1599
1590
 
1600
- /// Symbols in a group carry the weight of the system's own map controls;
1601
- /// a separate action keeps the lighter bar-button weight.
1591
+ /// Symbols in a group carry the weight of the system's own map controls,
1592
+ /// and their size: at 20 points a symbol filled its 48-point action
1593
+ /// where the ones in Maps leave half of theirs clear. The control and
1594
+ /// its touch targets keep their size; only the symbol is smaller. A
1595
+ /// separate action keeps the lighter bar-button weight.
1602
1596
  private static func accessorySymbol(grouped: Bool) -> UIImage.SymbolConfiguration {
1603
- UIImage.SymbolConfiguration(pointSize: grouped ? 20 : 22, weight: grouped ? .medium : .regular)
1597
+ UIImage.SymbolConfiguration(pointSize: grouped ? 15 : 22, weight: grouped ? .medium : .regular)
1598
+ }
1599
+
1600
+ /// One action's symbol, tint and state. An action that is on (its
1601
+ /// destination is showing, or the app says so) is drawn in the tint and,
1602
+ /// where the symbol has one, filled: Maps' location arrow while tracking.
1603
+ /// Off, a grouped action is the label colour, as the map controls are.
1604
+ private func styleAccessory(_ button: UIButton, for action: CraftChrome.Tab, grouped: Bool) {
1605
+ let active = CraftChrome.shared.accessoryActive(action)
1606
+ let filled = active && !action.symbol.hasSuffix(".fill") ? UIImage(systemName: action.symbol + ".fill") : nil
1607
+ let tint: UIColor = CraftChrome.shared.tint ?? config.tintColor.flatMap { UIColor(hex: $0) } ?? view.tintColor
1608
+ button.configuration?.image = filled ?? UIImage(systemName: action.symbol)
1609
+ button.configuration?.preferredSymbolConfigurationForImage = Self.accessorySymbol(grouped: grouped)
1610
+ // Said to the configuration as well as the view: glass draws its
1611
+ // content in the label colour unless the content names its own.
1612
+ button.configuration?.baseForegroundColor = active ? tint : grouped ? .label : nil
1613
+ // A group places each symbol itself, when it lays out.
1614
+ if !grouped { button.configuration?.contentInsets = .zero }
1615
+ button.tintColor = active ? tint : grouped ? .label : nil
1616
+ button.accessibilityTraits = active ? [.button, .selected] : [.button]
1604
1617
  }
1605
1618
 
1606
1619
  /// The surface under the floating actions. A group is a capsule of
@@ -1639,10 +1652,11 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1639
1652
  }
1640
1653
  }
1641
1654
 
1642
- /// A group's breadth, and each action's length along it: the system map
1643
- /// controls' proportions, every target at least 48 points.
1655
+ /// A group's breadth, and the distance from one symbol to the next. As
1656
+ /// in Maps, the first and last symbols sit at the centres of the
1657
+ /// capsule's round ends, so two actions make a 48 by 103 point control.
1644
1658
  static let accessoryGroupBreadth: CGFloat = 48
1645
- static let accessoryGroupStep: CGFloat = 52
1659
+ static let accessoryGroupStep: CGFloat = 55
1646
1660
 
1647
1661
  @objc private func openTabAccessory() {
1648
1662
  if let accessoryModel { hybrid.open(accessoryModel.id) }
@@ -1694,7 +1708,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1694
1708
  let breadth = Self.accessoryGroupBreadth
1695
1709
  let step = Self.accessoryGroupStep
1696
1710
  let buttons = companionButtons + [accessoryButton]
1697
- let length = step * CGFloat(buttons.count)
1711
+ let length = breadth + step * CGFloat(buttons.count - 1)
1698
1712
  let size = vertical ? CGSize(width: breadth, height: length) : CGSize(width: length, height: breadth)
1699
1713
  let rightToLeft = view.effectiveUserInterfaceLayoutDirection == .rightToLeft
1700
1714
  // The tab bar's own platter sits this far in from the screen's edge.
@@ -1702,10 +1716,21 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1702
1716
  let x = rightToLeft ? view.safeAreaInsets.left + margin : view.bounds.maxX - view.safeAreaInsets.right - size.width - margin
1703
1717
  accessoryGlass.frame = CGRect(x: x, y: tabBar.frame.minY - size.height - 10, width: size.width, height: size.height)
1704
1718
  for (index, button) in buttons.enumerated() {
1705
- let place = CGFloat(rightToLeft && !vertical ? buttons.count - 1 - index : index) * step
1719
+ // Every point of the capsule belongs to the nearest action; its
1720
+ // symbol stays where Maps puts it, at the centre of a round end
1721
+ // for the first and the last.
1722
+ let place = rightToLeft && !vertical ? buttons.count - 1 - index : index
1723
+ let centre = breadth / 2 + CGFloat(place) * step
1724
+ let from = place == 0 ? 0 : centre - step / 2
1725
+ let to = place == buttons.count - 1 ? length : centre + step / 2
1706
1726
  button.frame = vertical
1707
- ? CGRect(x: 0, y: place, width: breadth, height: step)
1708
- : CGRect(x: place, y: 0, width: step, height: breadth)
1727
+ ? CGRect(x: 0, y: from, width: breadth, height: to - from)
1728
+ : CGRect(x: from, y: 0, width: to - from, height: breadth)
1729
+ let shift = 2 * (centre - from) - (to - from)
1730
+ let before = max(0, shift), after = max(0, -shift)
1731
+ button.configuration?.contentInsets = vertical
1732
+ ? NSDirectionalEdgeInsets(top: before, leading: 0, bottom: after, trailing: 0)
1733
+ : NSDirectionalEdgeInsets(top: 0, leading: rightToLeft ? after : before, bottom: 0, trailing: rightToLeft ? before : after)
1709
1734
  }
1710
1735
  let badgeWidth = max(18, accessoryBadge.intrinsicContentSize.width + 8)
1711
1736
  let primary = accessoryButton.convert(accessoryButton.bounds, to: view)
@@ -391,7 +391,7 @@ enum CraftNativeActions {
391
391
  complete(requestToken, failure("CAPABILITY_DISABLED", "Local database is disabled"), completion)
392
392
  return
393
393
  }
394
- databaseQueue.async { complete(requestToken, database(method: method, args: args), completion) }
394
+ databaseQueue.async { complete(requestToken, database(requestToken: requestToken, method: method, args: args), completion) }
395
395
  return
396
396
  case ("Browser", "open"):
397
397
  // SFSafariViewController, or for `mode: 'auth'` the system's web
@@ -1275,7 +1275,47 @@ enum CraftNativeActions {
1275
1275
  #endif
1276
1276
  }
1277
1277
 
1278
- private static func database(method: String, args: [Any]) -> Result<Any, CraftNativeActionError> {
1278
+ /// A screen has gone. A transaction it began and never finished is rolled
1279
+ /// back.
1280
+ ///
1281
+ /// The database is one connection shared by every screen, and a
1282
+ /// transaction left open on it does not end with the screen that began
1283
+ /// it: every later write joins it, reads back as written, and is gone
1284
+ /// when the process exits, since nobody is left to commit; every later
1285
+ /// `beginTransaction` is refused. Rolling back loses what the closed
1286
+ /// screen had not committed, which is what closing mid-transaction means,
1287
+ /// and gives every later write its own fate back. Queued behind whatever
1288
+ /// that screen had already asked for.
1289
+ static func endScope(_ scope: String) {
1290
+ databaseQueue.async {
1291
+ guard transactionScope == scope else { return }
1292
+ transactionScope = nil
1293
+ guard let database, sqlite3_get_autocommit(database) == 0 else { return }
1294
+ _ = execute(sql: "ROLLBACK", params: [])
1295
+ }
1296
+ }
1297
+
1298
+ /// Which screen's transaction is open, read off the database around each
1299
+ /// call rather than off the method's name, so a `BEGIN` sent through
1300
+ /// `execute` is owned like one sent through `beginTransaction`: a call
1301
+ /// that leaves a transaction open where there was none began it, and one
1302
+ /// that leaves none ended it. The scope is the part of a request token
1303
+ /// before its last `/`. Only touched on `databaseQueue`.
1304
+ private static var transactionScope: String?
1305
+
1306
+ private static func database(requestToken: String, method: String, args: [Any]) -> Result<Any, CraftNativeActionError> {
1307
+ let before = database.map { sqlite3_get_autocommit($0) == 0 } ?? false
1308
+ let answer = databaseCall(method: method, args: args)
1309
+ let after = database.map { sqlite3_get_autocommit($0) == 0 } ?? false
1310
+ if after && !before {
1311
+ transactionScope = requestToken.range(of: "/", options: .backwards).map { String(requestToken[..<$0.lowerBound]) } ?? ""
1312
+ } else if !after {
1313
+ transactionScope = nil
1314
+ }
1315
+ return answer
1316
+ }
1317
+
1318
+ private static func databaseCall(method: String, args: [Any]) -> Result<Any, CraftNativeActionError> {
1279
1319
  guard openDatabase() else { return failure("DATABASE_ERROR", "Could not open the local database") }
1280
1320
  switch method {
1281
1321
  case "execute":
@@ -1497,7 +1537,11 @@ enum CraftNativeActions {
1497
1537
  "host": url.host ?? "",
1498
1538
  "path": url.path,
1499
1539
  "query": url.query ?? "",
1500
- "queryParams": Dictionary(uniqueKeysWithValues: (components?.queryItems ?? []).map { ($0.name, $0.value ?? "") }),
1540
+ // A link is whatever its sender wrote, and may name a parameter
1541
+ // twice. Built with unique keys this trapped, and the app closed
1542
+ // on being opened by such a link. The first value is kept, which
1543
+ // is the one Android gives.
1544
+ "queryParams": Dictionary((components?.queryItems ?? []).map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { first, _ in first }),
1501
1545
  ]
1502
1546
  }
1503
1547
 
@@ -902,8 +902,15 @@ enum CraftNativeFiles {
902
902
  guard let text = data as? String else {
903
903
  return .failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.write needs its contents as a string"))
904
904
  }
905
+ // Absent or null is utf8. Anything else that is not one of the two
906
+ // names is refused, a number included, rather than read as utf8.
907
+ let encoding: String?
908
+ switch options["encoding"] {
909
+ case nil, is NSNull: encoding = "utf8"
910
+ case let named: encoding = named as? String
911
+ }
905
912
  let bytes: Data
906
- switch options["encoding"] as? String ?? "utf8" {
913
+ switch encoding {
907
914
  case "utf8":
908
915
  bytes = Data(text.utf8)
909
916
  case "base64":
@@ -4798,14 +4798,23 @@ final class CraftPressFeedbackDelegate: NSObject, UIGestureRecognizerDelegate {
4798
4798
 
4799
4799
  /// Whether a scrolling view's gesture runs together with `other`. A scroll
4800
4800
  /// view is the delegate of its own pan as well as of its keyboard tap, so this
4801
- /// answers for both. Everything runs together (the keyboard tap watches
4801
+ /// answers for both. Most things run together (the keyboard tap watches
4802
4802
  /// alongside a control's own gestures; the back-swipe and the tab swipe decide
4803
- /// for themselves) except two scroll views' pans: run together, a drag on a
4804
- /// nested scroll view scrolled its parent at the same time.
4803
+ /// for themselves), with two exceptions for a scroll view's pan.
4804
+ ///
4805
+ /// Another scroll view's pan: run together, a drag on a nested scroll view
4806
+ /// scrolled its parent at the same time.
4807
+ ///
4808
+ /// A press. A row or a card that can be pressed is a view with a tap
4809
+ /// recognizer, and UIKit fails a tap the moment a drag begins on the scroll
4810
+ /// view around it. That is what keeps a flick, or the touch that stops a
4811
+ /// moving list, from also pressing whatever is under the finger. Told to run
4812
+ /// together, both happened: a short scroll pressed the thing it began on.
4805
4813
  func craftScrollGesture(_ gesture: UIGestureRecognizer, runsWith other: UIGestureRecognizer) -> Bool {
4806
- guard let scroll = gesture.view as? UIScrollView, gesture === scroll.panGestureRecognizer,
4807
- let otherScroll = other.view as? UIScrollView, other === otherScroll.panGestureRecognizer else { return true }
4808
- return false
4814
+ guard let scroll = gesture.view as? UIScrollView, gesture === scroll.panGestureRecognizer else { return true }
4815
+ if let otherScroll = other.view as? UIScrollView, other === otherScroll.panGestureRecognizer { return false }
4816
+ if other is UITapGestureRecognizer, other.delegate is CraftPressGestureDelegate { return false }
4817
+ return true
4809
4818
  }
4810
4819
 
4811
4820
  /// Scroll containers observe taps without cancelling native input focus or
@@ -6930,6 +6939,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6930
6939
  private var runningAnimation: CraftNativeRunningAnimation?
6931
6940
  private var renderedRoot: RenderedNode?
6932
6941
  private weak var lastFocusedInput: UIView?
6942
+ /// On its way off screen, or off it: from `viewWillDisappear` until the
6943
+ /// screen is next about to appear.
6944
+ ///
6945
+ /// A screen ends editing as it starts to leave (#497), and its view stays
6946
+ /// in the window for the length of the transition. Whatever put focus
6947
+ /// back in that time brought the keyboard up for a screen that was then
6948
+ /// removed, where nothing could put it away: a render, and a sign-in
6949
+ /// screen makes one as it navigates; the acknowledgement an input had
6950
+ /// scheduled a tenth of a second before; a modal closing; `autoFocus`.
6951
+ /// Each of them asks this first.
6952
+ private var isLeavingScreen = false
6933
6953
 
6934
6954
  /// The person put the keyboard away (Return, or a tap on nothing in a
6935
6955
  /// scroll view): the last input is let go. Kept, the next render or
@@ -7035,6 +7055,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7035
7055
  let timers = self.timers
7036
7056
  CraftNativeScriptThread.run { timers.pending.values.forEach { $0.cancel() } }
7037
7057
  pendingCapabilityRequests.forEach { CraftNativeActions.cancel(requestToken: $0) }
7058
+ CraftNativeActions.endScope(capabilityScope)
7038
7059
  lifecycleObservers.forEach(NotificationCenter.default.removeObserver)
7039
7060
  if let deepLinkListener = deepLinkListener { DeepLinkManager.shared.removeNativeListener(deepLinkListener) }
7040
7061
  }
@@ -7091,6 +7112,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7091
7112
 
7092
7113
  override func viewWillAppear(_ animated: Bool) {
7093
7114
  super.viewWillAppear(animated)
7115
+ isLeavingScreen = false
7094
7116
  if let navigationBarHidden, navigationController?.isNavigationBarHidden != navigationBarHidden {
7095
7117
  navigationController?.setNavigationBarHidden(navigationBarHidden, animated: animated)
7096
7118
  } else if navigationBarHidden == nil, navigationController?.isNavigationBarHidden == true,
@@ -7133,7 +7155,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7133
7155
  //
7134
7156
  // `lastFocusedInput` is deliberately left alone. A screen merely
7135
7157
  // covered by a push comes back, and restoreFocus putting the person
7136
- // back where they were is the behaviour that was already there.
7158
+ // back where they were is the behaviour that was already there. It
7159
+ // is not consulted again until the screen is coming back, or what
7160
+ // is ended here would be started again a moment later.
7161
+ isLeavingScreen = true
7137
7162
  view.endEditing(true)
7138
7163
  screenVisibilityChanged(false)
7139
7164
  stopCelebrations(in: view)
@@ -7298,6 +7323,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7298
7323
  pendingCapabilityDeadlines.values.forEach { $0.cancel() }
7299
7324
  pendingCapabilityDeadlines.removeAll()
7300
7325
  pendingCapabilityRequests.forEach { CraftNativeActions.cancel(requestToken: $0) }
7326
+ CraftNativeActions.endScope(capabilityScope)
7301
7327
  pendingCapabilityRequests.removeAll()
7302
7328
  if let owner = voiceRecordingOwner {
7303
7329
  voiceRecordingOwner = nil
@@ -9307,7 +9333,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9307
9333
  reply(.failure(CraftNativeActionError(code: "UNSUPPORTED_TARGET", message: "Keyboard focus is supported only for text inputs")))
9308
9334
  return true
9309
9335
  }
9310
- guard node.view.becomeFirstResponder() else {
9336
+ // A screen that is leaving does not open a keyboard it will
9337
+ // not be there to close.
9338
+ guard !isLeavingScreen, node.view.becomeFirstResponder() else {
9311
9339
  reply(.failure(CraftNativeActionError(code: "FOCUS_FAILED", message: "The mounted view refused keyboard focus")))
9312
9340
  return true
9313
9341
  }
@@ -9652,6 +9680,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9652
9680
  }
9653
9681
 
9654
9682
  private func restoreFocus(_ focused: UIView?) {
9683
+ guard !isLeavingScreen else { return }
9655
9684
  guard let focused, focused.window != nil, !focused.isFirstResponder,
9656
9685
  lastFocusedInput === focused, firstResponder(in: rootStack) == nil else { return }
9657
9686
  var ancestor: UIView? = focused
@@ -9862,7 +9891,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9862
9891
  updateAuxiliaryHandler(events["onEndEditing"], in: &endEditingHandlers, for: field)
9863
9892
  updateAuxiliaryHandler(events["onSubmitEditing"], in: &submitHandlers, for: field)
9864
9893
  if props["autoFocus"] as? Bool == true, !field.isFirstResponder {
9865
- DispatchQueue.main.async { _ = field.becomeFirstResponder() }
9894
+ DispatchQueue.main.async { [weak self] in
9895
+ guard self?.isLeavingScreen == false else { return }
9896
+ _ = field.becomeFirstResponder()
9897
+ }
9866
9898
  }
9867
9899
  case "SegmentedControl":
9868
9900
  let control = result as! CraftNativeSegmentedControl
@@ -9982,7 +10014,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9982
10014
  if !visible, wasVisible == true {
9983
10015
  let restore = modalFocusRestore.removeValue(forKey: modalID)
9984
10016
  DispatchQueue.main.async { [weak self, weak restore] in
9985
- guard let self, let restore, restore.window != nil, restore.isDescendant(of: self.view) else { return }
10017
+ guard let self, !self.isLeavingScreen, let restore, restore.window != nil, restore.isDescendant(of: self.view) else { return }
9986
10018
  self.lastFocusedInput = restore
9987
10019
  _ = restore.becomeFirstResponder()
9988
10020
  }
@@ -11185,7 +11217,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11185
11217
  updateAuxiliaryHandler(events["onEndEditing"], in: &endEditingHandlers, for: textView)
11186
11218
  updateAuxiliaryHandler(events["onSubmitEditing"], in: &submitHandlers, for: textView)
11187
11219
  if props["autoFocus"] as? Bool == true, !textView.isFirstResponder {
11188
- DispatchQueue.main.async { _ = textView.becomeFirstResponder() }
11220
+ DispatchQueue.main.async { [weak self] in
11221
+ guard self?.isLeavingScreen == false else { return }
11222
+ _ = textView.becomeFirstResponder()
11223
+ }
11189
11224
  }
11190
11225
  }
11191
11226
 
@@ -13,11 +13,22 @@ enum CraftPersistentStorage {
13
13
  let digest = SHA256.hash(data: Data(key.utf8)).map { String(format: "%02x", $0) }.joined()
14
14
  return directory.appendingPathComponent(digest + ".json")
15
15
  }
16
+ /// What an entry file holds, or nil when it is there and is not an entry.
17
+ ///
18
+ /// A file that cannot be parsed is a corrupt value, as one that parses to
19
+ /// the wrong thing is, and an app starts that value again. Only a file
20
+ /// that cannot be read at all is the disk failing, which an app retries;
21
+ /// that still throws. The two used to share the disk's answer.
22
+ private static func entry(at source: URL) throws -> [String: Any]? {
23
+ let contents = try Data(contentsOf: source)
24
+ return (try? JSONSerialization.jsonObject(with: contents)) as? [String: Any]
25
+ }
26
+
16
27
  static func get(_ key: String, prefix: String, storageDirectory: URL? = nil) throws -> Data? {
17
28
  lock.lock(); defer { lock.unlock() }
18
29
  let source = path(key, directory: storageDirectory ?? directory)
19
30
  if FileManager.default.fileExists(atPath: source.path) {
20
- guard let saved = try JSONSerialization.jsonObject(with: Data(contentsOf: source)) as? [String: Any], saved["key"] as? String == key else {
31
+ guard let saved = try entry(at: source), saved["key"] as? String == key else {
21
32
  throw CraftNativeActionError(code: "STORAGE_CORRUPT", message: "Invalid stored value")
22
33
  }
23
34
  if saved["deleted"] as? Bool == true { return nil }
@@ -26,7 +37,9 @@ enum CraftPersistentStorage {
26
37
  }
27
38
  guard let legacy = UserDefaults.standard.data(forKey: prefix + key) else { return nil }
28
39
  // Only valid legacy JSON qualifies for deferred migration on I/O failure.
29
- _ = try JSONSerialization.jsonObject(with: legacy, options: .fragmentsAllowed)
40
+ guard (try? JSONSerialization.jsonObject(with: legacy, options: .fragmentsAllowed)) != nil else {
41
+ throw CraftNativeActionError(code: "STORAGE_CORRUPT", message: "Stored value is not valid JSON")
42
+ }
30
43
  // Reading a valid legacy checkpoint must survive a full disk. Migration
31
44
  // can be retried on a later read; only a successful set promises a new
32
45
  // durable checkpoint and permits the application to acknowledge it.
@@ -84,9 +97,11 @@ enum CraftPersistentStorage {
84
97
  var result = Set(UserDefaults.standard.dictionaryRepresentation().keys.filter { $0.hasPrefix(prefix) }.map { String($0.dropFirst(prefix.count)) })
85
98
  if FileManager.default.fileExists(atPath: directory.path) {
86
99
  for source in try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) where source.pathExtension == "json" {
87
- guard let entry = try JSONSerialization.jsonObject(with: Data(contentsOf: source)) as? [String: Any], let key = entry["key"] as? String else {
88
- throw CraftNativeActionError(code: "STORAGE_CORRUPT", message: "Invalid stored value")
89
- }
100
+ // A file that is not an entry names no key. It used to fail
101
+ // the whole listing, and `clear` with it, over a value
102
+ // neither call had been asked about. `get` of its key still
103
+ // answers STORAGE_CORRUPT.
104
+ guard let entry = try entry(at: source), let key = entry["key"] as? String else { continue }
90
105
  if entry["deleted"] as? Bool == true { result.remove(key) } else { result.insert(key) }
91
106
  }
92
107
  }
@@ -95,5 +110,13 @@ enum CraftPersistentStorage {
95
110
  static func clear(prefix: String) throws {
96
111
  lock.lock(); defer { lock.unlock() }
97
112
  for key in try keys(prefix: prefix) { try set(key, data: nil, prefix: prefix) }
113
+ // Clearing a key writes over its file, so what is still not an entry
114
+ // belongs to no key this store knows, and cannot be marked removed.
115
+ // It is deleted. No earlier value can come back in its place: every
116
+ // one this store mirrors was removed just above.
117
+ guard FileManager.default.fileExists(atPath: directory.path) else { return }
118
+ for source in try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) where source.pathExtension == "json" {
119
+ if try entry(at: source)?["key"] as? String == nil { try FileManager.default.removeItem(at: source) }
120
+ }
98
121
  }
99
122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.497",
3
+ "version": "0.0.499",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",