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.
- package/dist/android/templates/CraftAppLinks.kt.template +28 -0
- package/dist/android/templates/CraftBridge.kt.template +3 -29
- package/dist/android/templates/CraftNativeBackground.kt.template +21 -2
- package/dist/android/templates/CraftNativeCapabilities.kt.template +145 -28
- package/dist/android/templates/CraftNativeShell.kt.template +48 -2
- package/dist/android/templates/CraftNativeSounds.kt.template +22 -3
- package/dist/android/templates/MainActivityNative.kt.template +19 -13
- package/dist/browser.js +27 -5
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +8 -1
- package/dist/ios/templates/CraftHybrid.swift +47 -22
- package/dist/ios/templates/CraftNativeActions.swift +47 -3
- package/dist/ios/templates/CraftNativeFiles.swift +8 -1
- package/dist/ios/templates/CraftNativeScreen.swift +46 -11
- package/dist/ios/templates/CraftPersistentStorage.swift +28 -5
- package/package.json +1 -1
|
@@ -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
|
-
|
|
5355
|
-
val json =
|
|
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
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
1463
|
+
val primarySelected = accessorySpec?.let { isAccessoryActive(it) } == true
|
|
1418
1464
|
accessory.isSelected = primarySelected
|
|
1419
1465
|
companionViews.forEach { (action, item) ->
|
|
1420
|
-
val selected =
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
8727
|
-
|
|
8728
|
-
"
|
|
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
|
-
|
|
8869
|
-
|
|
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
|
|
913
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
package/dist/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
///
|
|
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 ?
|
|
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
|
|
1643
|
-
///
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
1708
|
-
: CGRect(x:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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)
|
|
4804
|
-
///
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
}
|