craft-native 0.0.385 → 0.0.390
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/README.md +41 -0
- package/dist/android/templates/CraftNativeCapabilities.kt.template +107 -39
- package/dist/android/templates/MainActivityNative.kt.template +2 -0
- package/dist/browser/checkpoints.d.ts +6 -0
- package/dist/browser.js +186 -26
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftNativeActions.swift +4 -3
- package/dist/ios/templates/CraftNativeFiles.swift +227 -94
- package/dist/ios/templates/CraftNativeScreen.swift +39 -12
- package/dist/types.d.ts +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -666,12 +666,53 @@ values (including their `toJSON` representation). Invalid JSON input rejects
|
|
|
666
666
|
without changing the previous checkpoint. Cancellation and account-scope checks
|
|
667
667
|
still run before committing the captured value.
|
|
668
668
|
|
|
669
|
+
Browser checkpoint calls use IndexedDB read/write transactions and acknowledge
|
|
670
|
+
only transaction completion. Web Locks alone cannot make localStorage caches
|
|
671
|
+
coherent across tabs (observed in WebKit). Web Locks still serialize admission
|
|
672
|
+
and support queued cancellation; atomic capabilities require both APIs. Storage
|
|
673
|
+
exceptions reject the call. Cancelling an active transaction aborts it; an
|
|
674
|
+
already completed transaction cannot be undone.
|
|
675
|
+
|
|
676
|
+
Existing account-scoped Storage values are migrated once, before the first
|
|
677
|
+
transactional operation. localStorage remains a synchronous UI mirror and keeps
|
|
678
|
+
its quota checks, but the committed database is authoritative after migration.
|
|
679
|
+
A failed transaction attempts to restore the previous mirror; the next
|
|
680
|
+
successful checkpoint call repairs it from the database. Direct localStorage
|
|
681
|
+
edits and older Craft tabs cannot update the new checkpoint database: reload
|
|
682
|
+
all tabs when upgrading. Snapshots, credentials and unrelated app keys are not
|
|
683
|
+
migrated. This does not change browser security or promise power-loss durability.
|
|
684
|
+
|
|
669
685
|
`craft.storage.setSync` updates the immediate UI cache and
|
|
670
686
|
queues its write when Web Locks are available; it cannot acknowledge durability
|
|
671
687
|
or propagate a later quota failure. Background events are an opportunity to
|
|
672
688
|
checkpoint, but browsers can terminate a page before queued work finishes, so
|
|
673
689
|
also save periodically during play.
|
|
674
690
|
|
|
691
|
+
Real-browser checkpoint acceptance runs separately from the SDK's in-memory
|
|
692
|
+
unit fixtures:
|
|
693
|
+
|
|
694
|
+
```sh
|
|
695
|
+
bunx --no-install playwright install chromium firefox webkit
|
|
696
|
+
bun run test:browser-checkpoints
|
|
697
|
+
CRAFT_TEST_BROWSER=firefox bun run test:browser-checkpoints
|
|
698
|
+
CRAFT_TEST_BROWSER=webkit bun run test:browser-checkpoints
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
The suite bundles the maintained `createBrowserPlatform` source, serves it on
|
|
702
|
+
loopback, and uses fresh, owned persistent profiles with real IndexedDB,
|
|
703
|
+
localStorage and Web Locks. It verifies acknowledged saves across reload and a graceful browser
|
|
704
|
+
restart, competing-tab create/CAS winners and retry loops, queued-write input
|
|
705
|
+
capture, reload/abort/disposal before commit, and actual quota rejection with
|
|
706
|
+
recovery, legacy migration, stale-cache recovery and account cleanup. Fault
|
|
707
|
+
injection aborts real transactions after put succeeds to verify rollback and
|
|
708
|
+
cancellation before commit. It never opens or modifies your personal browser
|
|
709
|
+
profile. CI runs all three engines and retains browser versions, the bundle
|
|
710
|
+
SHA-256, results and traces.
|
|
711
|
+
Set `CRAFT_BROWSER_ARTIFACTS` to retain these in a chosen directory; locally the
|
|
712
|
+
default is a printed temporary path. Profiles are removed after each test.
|
|
713
|
+
This proves the tested browser boundaries, not sudden process termination,
|
|
714
|
+
power-loss durability, storage eviction, shipping Safari/iOS versions or Android acceptance.
|
|
715
|
+
|
|
675
716
|
Browser audio assigns one owner per sound ID. A newer play or stop invalidates
|
|
676
717
|
pending media resolution for that ID; stopping all sounds, changing accounts,
|
|
677
718
|
aborting a call or disposing the host also releases active audio. Superseded
|
|
@@ -46,7 +46,9 @@ import kotlin.math.roundToInt
|
|
|
46
46
|
|
|
47
47
|
private const val CRAFT_NATIVE_FETCH_MAX_RESPONSE_BYTES = 8 * 1024 * 1024
|
|
48
48
|
private const val CRAFT_NATIVE_FETCH_TIMEOUT_MS = 30_000
|
|
49
|
-
private const val
|
|
49
|
+
private const val CRAFT_NATIVE_FILE_PICK_FIRST = 0x4000
|
|
50
|
+
private const val CRAFT_NATIVE_FILE_PICK_LAST = 0xffff
|
|
51
|
+
private const val CRAFT_NATIVE_FILE_PICK_STATE = "craft.filePicker.nextCode"
|
|
50
52
|
private const val CRAFT_NATIVE_NOTIFICATION_PERMISSION_REQUEST = 4102
|
|
51
53
|
private const val CRAFT_NATIVE_FILE_DEFAULT_MAX_PIXELS = 2048
|
|
52
54
|
private const val CRAFT_NATIVE_FILE_LARGEST_MAX_PIXELS = 8192
|
|
@@ -61,11 +63,41 @@ internal data class CraftNativeCapabilityResult(
|
|
|
61
63
|
val message: String? = null,
|
|
62
64
|
)
|
|
63
65
|
|
|
66
|
+
/** Never recycle a code while an old Activity result could still arrive. */
|
|
67
|
+
internal class CraftNativePickerCodes(private var next: Int = CRAFT_NATIVE_FILE_PICK_FIRST) {
|
|
68
|
+
@Synchronized fun allocate(): Int? = if (next <= CRAFT_NATIVE_FILE_PICK_LAST) next++ else null
|
|
69
|
+
@Synchronized fun snapshot(): Int = next
|
|
70
|
+
@Synchronized fun restore(value: Int) {
|
|
71
|
+
next = maxOf(next, value.coerceIn(CRAFT_NATIVE_FILE_PICK_FIRST, CRAFT_NATIVE_FILE_PICK_LAST + 1))
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Injectable platform boundary; result matching and file IO stay in the host. */
|
|
76
|
+
internal interface CraftNativePickerHost {
|
|
77
|
+
fun launch(intent: Intent, requestCode: Int)
|
|
78
|
+
fun dismiss(requestCode: Int)
|
|
79
|
+
}
|
|
80
|
+
|
|
64
81
|
/** Process-wide persistence and OS services shared by every native route. */
|
|
65
82
|
internal class CraftNativeCapabilities(
|
|
66
83
|
private val context: Context,
|
|
67
84
|
private val config: JSONObject,
|
|
85
|
+
private val pickerHost: CraftNativePickerHost? = (context as? MainActivity)?.let { host ->
|
|
86
|
+
object : CraftNativePickerHost {
|
|
87
|
+
override fun launch(intent: Intent, requestCode: Int) { host.startActivityForResult(intent, requestCode) }
|
|
88
|
+
override fun dismiss(requestCode: Int) { host.finishActivity(requestCode) }
|
|
89
|
+
}
|
|
90
|
+
},
|
|
68
91
|
) {
|
|
92
|
+
companion object {
|
|
93
|
+
private val pickerCodes = CraftNativePickerCodes()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
fun savePickerState(state: android.os.Bundle) { state.putInt(CRAFT_NATIVE_FILE_PICK_STATE, pickerCodes.snapshot()) }
|
|
97
|
+
fun restorePickerState(state: android.os.Bundle?) {
|
|
98
|
+
pickerCodes.restore(state?.getInt(CRAFT_NATIVE_FILE_PICK_STATE, CRAFT_NATIVE_FILE_PICK_FIRST) ?: CRAFT_NATIVE_FILE_PICK_FIRST)
|
|
99
|
+
}
|
|
100
|
+
|
|
69
101
|
/** The screen's activity; null in a background refresh, where nothing can be shown. */
|
|
70
102
|
private val activity: MainActivity? get() = context as? MainActivity
|
|
71
103
|
private val bluetooth by lazy { CraftBluetooth(context) }
|
|
@@ -114,6 +146,7 @@ internal class CraftNativeCapabilities(
|
|
|
114
146
|
val videoLimit: Long = 0,
|
|
115
147
|
val selectionLimit: Int = 1,
|
|
116
148
|
val fileLimit: Long = 0,
|
|
149
|
+
val requestCode: Int = -1,
|
|
117
150
|
)
|
|
118
151
|
|
|
119
152
|
private val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
@@ -234,7 +267,12 @@ internal class CraftNativeCapabilities(
|
|
|
234
267
|
fun cancel(requestToken: String) {
|
|
235
268
|
cancelled.add(requestToken)
|
|
236
269
|
fetchConnections.remove(requestToken)?.disconnect()
|
|
237
|
-
|
|
270
|
+
onMain {
|
|
271
|
+
if (pendingFilePick?.requestToken == requestToken) {
|
|
272
|
+
dismissFilePick()
|
|
273
|
+
cancelled.remove(requestToken)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
238
276
|
biometricPrompts.remove(requestToken)?.cancelAuthentication()
|
|
239
277
|
}
|
|
240
278
|
|
|
@@ -247,13 +285,41 @@ internal class CraftNativeCapabilities(
|
|
|
247
285
|
networkExecutor.shutdownNow()
|
|
248
286
|
fetchConnections.values.forEach(HttpURLConnection::disconnect)
|
|
249
287
|
fetchConnections.clear()
|
|
250
|
-
|
|
288
|
+
onMain { dismissFilePick() }
|
|
251
289
|
database?.close()
|
|
252
290
|
database = null
|
|
253
291
|
biometricPrompts.values.forEach { it.cancelAuthentication() }
|
|
254
292
|
biometricPrompts.clear()
|
|
255
293
|
}
|
|
256
294
|
|
|
295
|
+
private fun onMain(work: () -> Unit) {
|
|
296
|
+
if (android.os.Looper.myLooper() == android.os.Looper.getMainLooper()) work()
|
|
297
|
+
else mainHandler.post { work() }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private fun dismissFilePick() {
|
|
301
|
+
val pending = pendingFilePick ?: return
|
|
302
|
+
pendingFilePick = null
|
|
303
|
+
// Clear ownership first: finishActivity may immediately return a result.
|
|
304
|
+
runCatching { pickerHost?.dismiss(pending.requestCode) }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private fun launchFilePick(host: CraftNativePickerHost, intent: Intent, pending: PendingFilePick) {
|
|
308
|
+
val code = pickerCodes.allocate()
|
|
309
|
+
if (code == null) {
|
|
310
|
+
complete(pending.requestToken, failure("PICKER_UNAVAILABLE", "Picker request codes exhausted; start a new Activity session"), pending.completion)
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
pendingFilePick = pending.copy(requestCode = code)
|
|
314
|
+
try { host.launch(intent, code) }
|
|
315
|
+
catch (error: Exception) {
|
|
316
|
+
if (pendingFilePick?.requestCode == code) {
|
|
317
|
+
pendingFilePick = null
|
|
318
|
+
complete(pending.requestToken, failure("PICKER_UNAVAILABLE", error.message ?: "Picker could not be opened"), pending.completion)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
257
323
|
/** The same bounded text-only fetch contract as the maintained iOS host. */
|
|
258
324
|
private fun fetch(requestToken: String, args: JSONArray): CraftNativeCapabilityResult {
|
|
259
325
|
val options = args.optJSONObject(0)
|
|
@@ -492,26 +558,22 @@ internal class CraftNativeCapabilities(
|
|
|
492
558
|
|
|
493
559
|
private fun pickVideo(requestToken: String, args: JSONArray, completion: (CraftNativeCapabilityResult) -> Unit) {
|
|
494
560
|
mainHandler.post {
|
|
495
|
-
if (fileRequestCancelled(requestToken)) return@post
|
|
496
|
-
val host =
|
|
561
|
+
if (fileRequestCancelled(requestToken)) { cancelled.remove(requestToken); return@post }
|
|
562
|
+
val host = pickerHost ?: return@post complete(requestToken, failure("NEEDS_ACTIVITY", "No screen is showing to pick from"), completion)
|
|
497
563
|
if (pendingFilePick != null) {
|
|
498
564
|
complete(requestToken, failure("BUSY", "Another file picker is already open"), completion)
|
|
499
565
|
return@post
|
|
500
566
|
}
|
|
501
567
|
val options = args.optJSONObject(0) ?: JSONObject()
|
|
502
568
|
val limit = options.optLong("maxBytes", 512L * 1024 * 1024).coerceIn(1, 512L * 1024 * 1024)
|
|
503
|
-
|
|
569
|
+
val pending = PendingFilePick(requestToken, "video", 0, 1f, completion, limit)
|
|
504
570
|
val photos = options.optString("source", "library") != "files" && Build.VERSION.SDK_INT >= 33
|
|
505
571
|
val intent = Intent(if (photos) MediaStore.ACTION_PICK_IMAGES else Intent.ACTION_OPEN_DOCUMENT).apply {
|
|
506
572
|
type = "video/*"
|
|
507
573
|
if (!photos) addCategory(Intent.CATEGORY_OPENABLE)
|
|
508
574
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
509
575
|
}
|
|
510
|
-
|
|
511
|
-
catch (error: Exception) {
|
|
512
|
-
pendingFilePick = null
|
|
513
|
-
complete(requestToken, failure("PICKER_UNAVAILABLE", error.message ?: "Video picker could not be opened"), completion)
|
|
514
|
-
}
|
|
576
|
+
launchFilePick(host, intent, pending)
|
|
515
577
|
}
|
|
516
578
|
}
|
|
517
579
|
|
|
@@ -559,8 +621,8 @@ internal class CraftNativeCapabilities(
|
|
|
559
621
|
completion: (CraftNativeCapabilityResult) -> Unit,
|
|
560
622
|
) {
|
|
561
623
|
mainHandler.post {
|
|
562
|
-
if (fileRequestCancelled(requestToken)) return@post
|
|
563
|
-
val host =
|
|
624
|
+
if (fileRequestCancelled(requestToken)) { cancelled.remove(requestToken); return@post }
|
|
625
|
+
val host = pickerHost ?: return@post complete(requestToken, failure("NEEDS_ACTIVITY", "No screen is showing to pick from"), completion)
|
|
564
626
|
if (pendingFilePick != null) {
|
|
565
627
|
complete(requestToken, failure("BUSY", "Another file picker is already open"), completion)
|
|
566
628
|
return@post
|
|
@@ -579,25 +641,20 @@ internal class CraftNativeCapabilities(
|
|
|
579
641
|
complete(requestToken, failure("CAPABILITY_DISABLED", "Camera is disabled"), completion)
|
|
580
642
|
return@post
|
|
581
643
|
}
|
|
582
|
-
|
|
644
|
+
val pending = PendingFilePick(requestToken, source, maxPixels, quality, completion)
|
|
583
645
|
val intent = if (source == "camera") Intent(MediaStore.ACTION_IMAGE_CAPTURE)
|
|
584
646
|
else Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
|
585
647
|
addCategory(Intent.CATEGORY_OPENABLE)
|
|
586
648
|
type = "image/*"
|
|
587
649
|
}
|
|
588
|
-
|
|
589
|
-
host.startActivityForResult(intent, CRAFT_NATIVE_FILE_PICK_REQUEST)
|
|
590
|
-
} catch (error: Exception) {
|
|
591
|
-
pendingFilePick = null
|
|
592
|
-
complete(requestToken, failure("PICKER_UNAVAILABLE", error.message ?: "Image picker could not be opened"), completion)
|
|
593
|
-
}
|
|
650
|
+
launchFilePick(host, intent, pending)
|
|
594
651
|
}
|
|
595
652
|
}
|
|
596
653
|
|
|
597
654
|
private fun pickFile(requestToken: String, args: JSONArray, completion: (CraftNativeCapabilityResult) -> Unit) {
|
|
598
655
|
mainHandler.post {
|
|
599
|
-
if (fileRequestCancelled(requestToken)) return@post
|
|
600
|
-
val host =
|
|
656
|
+
if (fileRequestCancelled(requestToken)) { cancelled.remove(requestToken); return@post }
|
|
657
|
+
val host = pickerHost ?: return@post complete(requestToken, failure("NEEDS_ACTIVITY", "No screen is showing to pick from"), completion)
|
|
601
658
|
if (pendingFilePick != null) {
|
|
602
659
|
complete(requestToken, failure("BUSY", "Another file picker is already open"), completion)
|
|
603
660
|
return@post
|
|
@@ -610,7 +667,7 @@ internal class CraftNativeCapabilities(
|
|
|
610
667
|
}
|
|
611
668
|
val selections = options.optInt("maxSelections", 1).coerceIn(1, 20)
|
|
612
669
|
val limit = options.optLong("maxBytes", 512L * 1024 * 1024).coerceIn(1, 512L * 1024 * 1024)
|
|
613
|
-
|
|
670
|
+
val pending = PendingFilePick(
|
|
614
671
|
requestToken,
|
|
615
672
|
"file",
|
|
616
673
|
0,
|
|
@@ -626,11 +683,7 @@ internal class CraftNativeCapabilities(
|
|
|
626
683
|
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, selections > 1)
|
|
627
684
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
628
685
|
}
|
|
629
|
-
|
|
630
|
-
catch (error: Exception) {
|
|
631
|
-
pendingFilePick = null
|
|
632
|
-
complete(requestToken, failure("PICKER_UNAVAILABLE", error.message ?: "File picker could not be opened"), completion)
|
|
633
|
-
}
|
|
686
|
+
launchFilePick(host, intent, pending)
|
|
634
687
|
}
|
|
635
688
|
}
|
|
636
689
|
|
|
@@ -666,36 +719,39 @@ internal class CraftNativeCapabilities(
|
|
|
666
719
|
}
|
|
667
720
|
|
|
668
721
|
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
|
669
|
-
if (requestCode
|
|
722
|
+
if (requestCode !in CRAFT_NATIVE_FILE_PICK_FIRST..CRAFT_NATIVE_FILE_PICK_LAST) return false
|
|
670
723
|
val pending = pendingFilePick ?: return true
|
|
724
|
+
if (pending.requestCode != requestCode) return true
|
|
671
725
|
pendingFilePick = null
|
|
726
|
+
if (fileRequestCancelled(pending.requestToken)) {
|
|
727
|
+
cancelled.remove(pending.requestToken)
|
|
728
|
+
return true
|
|
729
|
+
}
|
|
672
730
|
if (resultCode != android.app.Activity.RESULT_OK) {
|
|
673
|
-
complete(pending.requestToken, success(JSONObject.NULL), pending.completion)
|
|
731
|
+
complete(pending.requestToken, success(if (pending.source == "file") JSONArray() else JSONObject.NULL), pending.completion)
|
|
674
732
|
return true
|
|
675
733
|
}
|
|
676
734
|
if (pending.source == "video") {
|
|
677
|
-
|
|
678
|
-
|
|
735
|
+
importPickedFile(pending) {
|
|
736
|
+
data?.data?.let { copyVideo(pending.requestToken, it, pending.videoLimit) }
|
|
679
737
|
?: failure("PICKER_FAILED", "The selected video could not be read")
|
|
680
|
-
completeFile(pending.requestToken, result, pending.completion)
|
|
681
738
|
}
|
|
682
739
|
return true
|
|
683
740
|
}
|
|
684
741
|
if (pending.source == "file") {
|
|
685
|
-
|
|
742
|
+
importPickedFile(pending) {
|
|
686
743
|
val uris = buildList {
|
|
687
744
|
data?.clipData?.let { clips ->
|
|
688
745
|
for (index in 0 until clips.itemCount) add(clips.getItemAt(index).uri)
|
|
689
746
|
}
|
|
690
747
|
data?.data?.let { add(it) }
|
|
691
748
|
}.distinct().take(pending.selectionLimit)
|
|
692
|
-
|
|
693
|
-
completeFile(pending.requestToken, result, pending.completion)
|
|
749
|
+
copyDocuments(pending.requestToken, uris, pending.fileLimit)
|
|
694
750
|
}
|
|
695
751
|
return true
|
|
696
752
|
}
|
|
697
|
-
|
|
698
|
-
|
|
753
|
+
importPickedFile(pending) {
|
|
754
|
+
runCatching {
|
|
699
755
|
val descriptor = if (pending.source == "camera") {
|
|
700
756
|
val bitmap = data?.extras?.get("data") as? Bitmap
|
|
701
757
|
bitmap?.let { writeImage(it, pending.maxPixels, pending.quality, "photo.jpg", pending.requestToken) }
|
|
@@ -705,11 +761,23 @@ internal class CraftNativeCapabilities(
|
|
|
705
761
|
descriptor?.let(::success) ?: if (fileRequestCancelled(pending.requestToken)) failure("CANCELLED", "Image selection was cancelled")
|
|
706
762
|
else failure("PICKER_FAILED", "The selected image could not be read")
|
|
707
763
|
}.getOrElse { failure("READ_ERROR", it.message ?: "The selected image could not be read") }
|
|
708
|
-
completeFile(pending.requestToken, result, pending.completion)
|
|
709
764
|
}
|
|
710
765
|
return true
|
|
711
766
|
}
|
|
712
767
|
|
|
768
|
+
private fun importPickedFile(pending: PendingFilePick, work: () -> CraftNativeCapabilityResult) {
|
|
769
|
+
try {
|
|
770
|
+
networkExecutor.execute {
|
|
771
|
+
val result = if (fileRequestCancelled(pending.requestToken)) failure("CANCELLED", "File selection was cancelled") else work()
|
|
772
|
+
completeFile(pending.requestToken, result, pending.completion)
|
|
773
|
+
}
|
|
774
|
+
} catch (_: java.util.concurrent.RejectedExecutionException) {
|
|
775
|
+
// close() can shut down the executor between result matching and
|
|
776
|
+
// submission. Its closed flag suppresses this abandoned reply.
|
|
777
|
+
complete(pending.requestToken, failure("CANCELLED", "File import host is closed"), pending.completion)
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
713
781
|
private fun copyUri(requestToken: String, uri: Uri, maxPixels: Int, quality: Float): JSONObject? {
|
|
714
782
|
if (fileRequestCancelled(requestToken)) return null
|
|
715
783
|
val name = context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
|
|
@@ -4425,6 +4425,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
4425
4425
|
}
|
|
4426
4426
|
}
|
|
4427
4427
|
capabilities = CraftNativeCapabilities(this, config)
|
|
4428
|
+
capabilities.restorePickerState(savedInstanceState)
|
|
4428
4429
|
routes = CraftNativeRoutes(config)
|
|
4429
4430
|
stores = CraftNativeStores.get(this, config)
|
|
4430
4431
|
services = CraftNativeServices(this, config) { this }
|
|
@@ -5233,6 +5234,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
5233
5234
|
}
|
|
5234
5235
|
|
|
5235
5236
|
override fun onSaveInstanceState(outState: Bundle) {
|
|
5237
|
+
if (::capabilities.isInitialized) capabilities.savePickerState(outState)
|
|
5236
5238
|
outState.putString("craft.initialDeepLink", initialDeepLink?.toString())
|
|
5237
5239
|
outState.putBoolean("craft.initialDeepLinkClaimed", initialDeepLinkClaimed)
|
|
5238
5240
|
outState.putBoolean("craft.deliveredInitialDeepLink", deliveredInitialDeepLink)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* localStorage is a synchronous UI mirror, not an atomic cross-process store.
|
|
3
|
+
* WebKit can grant a Web Lock before another process's storage cache updates.
|
|
4
|
+
* Keep the acknowledged checkpoint in one IndexedDB read/write transaction.
|
|
5
|
+
*/
|
|
6
|
+
export declare function createCheckpointStore(mirror: Storage, namespace: string): <T>(operation: (storage: Storage) => T, signal?: AbortSignal) => Promise<T>;
|
package/dist/browser.js
CHANGED
|
@@ -62,6 +62,149 @@ function secureUUID() {
|
|
|
62
62
|
throw new Error("[Craft] No Web Crypto available; cannot generate secure IDs");
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// src/browser/checkpoints.ts
|
|
66
|
+
function createCheckpointStore(mirror, namespace) {
|
|
67
|
+
const prefix = `${namespace}:account:`;
|
|
68
|
+
const owned = (key) => {
|
|
69
|
+
if (!key.startsWith(prefix))
|
|
70
|
+
return false;
|
|
71
|
+
const scoped = key.slice(prefix.length);
|
|
72
|
+
const separator = scoped.indexOf(":");
|
|
73
|
+
return separator !== -1 && scoped.slice(separator + 1).startsWith("storage:");
|
|
74
|
+
};
|
|
75
|
+
const mirrorKeys = () => Array.from({ length: mirror.length }, (_, i) => mirror.key(i)).filter((key) => key !== null && owned(key));
|
|
76
|
+
return async (operation, signal) => {
|
|
77
|
+
signal?.throwIfAborted();
|
|
78
|
+
const db = await new Promise((resolve, reject) => {
|
|
79
|
+
const request = indexedDB.open(`${namespace}:checkpoints`, 1);
|
|
80
|
+
let abandoned = false;
|
|
81
|
+
const abort = () => {
|
|
82
|
+
abandoned = true;
|
|
83
|
+
reject(signal?.reason);
|
|
84
|
+
};
|
|
85
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
86
|
+
request.onupgradeneeded = () => {
|
|
87
|
+
request.result.createObjectStore("checkpoints");
|
|
88
|
+
};
|
|
89
|
+
request.onerror = () => {
|
|
90
|
+
signal?.removeEventListener("abort", abort);
|
|
91
|
+
reject(request.error);
|
|
92
|
+
};
|
|
93
|
+
request.onblocked = () => {
|
|
94
|
+
abandoned = true;
|
|
95
|
+
signal?.removeEventListener("abort", abort);
|
|
96
|
+
reject(new DOMException("Checkpoint database upgrade is blocked", "InvalidStateError"));
|
|
97
|
+
};
|
|
98
|
+
request.onsuccess = () => {
|
|
99
|
+
signal?.removeEventListener("abort", abort);
|
|
100
|
+
if (abandoned || signal?.aborted) {
|
|
101
|
+
request.result.close();
|
|
102
|
+
reject(signal?.reason);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
resolve(request.result);
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
try {
|
|
109
|
+
await new Promise((resolve, reject) => {
|
|
110
|
+
const transaction = db.transaction("checkpoints", "readwrite");
|
|
111
|
+
const store = transaction.objectStore("checkpoints");
|
|
112
|
+
const request = store.get("state");
|
|
113
|
+
let failure;
|
|
114
|
+
request.onsuccess = () => {
|
|
115
|
+
if (request.result !== undefined)
|
|
116
|
+
return;
|
|
117
|
+
try {
|
|
118
|
+
store.put(mirrorKeys().map((key) => [key, mirror.getItem(key)]), "state");
|
|
119
|
+
} catch (error) {
|
|
120
|
+
failure = error;
|
|
121
|
+
transaction.abort();
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
transaction.oncomplete = () => resolve();
|
|
125
|
+
transaction.onabort = () => reject(failure ?? transaction.error);
|
|
126
|
+
});
|
|
127
|
+
signal?.throwIfAborted();
|
|
128
|
+
return await new Promise((resolve, reject) => {
|
|
129
|
+
const transaction = db.transaction("checkpoints", "readwrite");
|
|
130
|
+
const store = transaction.objectStore("checkpoints");
|
|
131
|
+
let result;
|
|
132
|
+
let failure;
|
|
133
|
+
const before = new Map;
|
|
134
|
+
const abort = () => {
|
|
135
|
+
failure = signal?.reason;
|
|
136
|
+
try {
|
|
137
|
+
transaction.abort();
|
|
138
|
+
} catch {}
|
|
139
|
+
};
|
|
140
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
141
|
+
transaction.oncomplete = () => {
|
|
142
|
+
signal?.removeEventListener("abort", abort);
|
|
143
|
+
resolve(result);
|
|
144
|
+
};
|
|
145
|
+
transaction.onabort = () => {
|
|
146
|
+
signal?.removeEventListener("abort", abort);
|
|
147
|
+
for (const [key, value] of before) {
|
|
148
|
+
try {
|
|
149
|
+
if (value === null)
|
|
150
|
+
mirror.removeItem(key);
|
|
151
|
+
else
|
|
152
|
+
mirror.setItem(key, value);
|
|
153
|
+
} catch {}
|
|
154
|
+
}
|
|
155
|
+
reject(failure ?? transaction.error ?? new DOMException("Checkpoint transaction aborted", "AbortError"));
|
|
156
|
+
};
|
|
157
|
+
const request = store.get("state");
|
|
158
|
+
request.onsuccess = () => {
|
|
159
|
+
try {
|
|
160
|
+
signal?.throwIfAborted();
|
|
161
|
+
const values = new Map(request.result);
|
|
162
|
+
let dirty = false;
|
|
163
|
+
const storage = {
|
|
164
|
+
get length() {
|
|
165
|
+
return values.size;
|
|
166
|
+
},
|
|
167
|
+
key: (index) => [...values.keys()][index] ?? null,
|
|
168
|
+
getItem: (key) => values.get(key) ?? null,
|
|
169
|
+
setItem(key, value) {
|
|
170
|
+
values.set(key, String(value));
|
|
171
|
+
dirty = true;
|
|
172
|
+
},
|
|
173
|
+
removeItem(key) {
|
|
174
|
+
dirty = values.delete(key) || dirty;
|
|
175
|
+
},
|
|
176
|
+
clear() {
|
|
177
|
+
dirty = values.size > 0 || dirty;
|
|
178
|
+
values.clear();
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
result = operation(storage);
|
|
182
|
+
signal?.throwIfAborted();
|
|
183
|
+
for (const key of new Set([...mirrorKeys(), ...values.keys()])) {
|
|
184
|
+
const previous = mirror.getItem(key);
|
|
185
|
+
const next = values.get(key) ?? null;
|
|
186
|
+
if (previous === next)
|
|
187
|
+
continue;
|
|
188
|
+
before.set(key, previous);
|
|
189
|
+
if (next === null)
|
|
190
|
+
mirror.removeItem(key);
|
|
191
|
+
else
|
|
192
|
+
mirror.setItem(key, next);
|
|
193
|
+
}
|
|
194
|
+
if (dirty)
|
|
195
|
+
store.put([...values], "state");
|
|
196
|
+
} catch (error) {
|
|
197
|
+
failure = error;
|
|
198
|
+
transaction.abort();
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
} finally {
|
|
203
|
+
db.close();
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
65
208
|
// src/browser/storage.ts
|
|
66
209
|
async function waitForAccount(operation, signal) {
|
|
67
210
|
if (!signal)
|
|
@@ -83,7 +226,13 @@ async function createBrowserStorage(options) {
|
|
|
83
226
|
const { storage, namespace, tokenKey, changed } = options;
|
|
84
227
|
const sessionTokenKey = options.sessionTokenKey || "auth.token";
|
|
85
228
|
const prefix = `${namespace}:`;
|
|
86
|
-
const lock = options.lock === null ? undefined : options.lock ?? (typeof navigator !== "undefined" && typeof navigator.locks?.request === "function" ? (name, operation, settings) => navigator.locks.request(name, settings ?? {},
|
|
229
|
+
const lock = options.lock === null ? undefined : options.lock ?? (typeof navigator !== "undefined" && typeof navigator.locks?.request === "function" ? (name, operation, settings) => navigator.locks.request(name, settings ?? {}, async () => {
|
|
230
|
+
try {
|
|
231
|
+
return await operation();
|
|
232
|
+
} finally {
|
|
233
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
234
|
+
}
|
|
235
|
+
}) : undefined);
|
|
87
236
|
const exclusive = (operation, signal) => {
|
|
88
237
|
const guarded = () => {
|
|
89
238
|
signal?.throwIfAborted();
|
|
@@ -92,6 +241,12 @@ async function createBrowserStorage(options) {
|
|
|
92
241
|
signal?.throwIfAborted();
|
|
93
242
|
return lock ? lock(`${namespace}:storage`, guarded, { signal }) : Promise.resolve().then(guarded);
|
|
94
243
|
};
|
|
244
|
+
const checkpoints = options.lock === undefined && lock && typeof indexedDB !== "undefined" ? createCheckpointStore(storage, namespace) : null;
|
|
245
|
+
const atomicStorage = !!lock && (!!checkpoints || typeof options.lock === "function");
|
|
246
|
+
const checkpoint = (operation, signal) => checkpoints ? checkpoints(operation, signal) : Promise.resolve().then(() => {
|
|
247
|
+
signal?.throwIfAborted();
|
|
248
|
+
return operation(storage);
|
|
249
|
+
});
|
|
95
250
|
const scopeChanged = () => Object.assign(new Error("The storage account changed"), { code: "STORAGE_SCOPE_CHANGED" });
|
|
96
251
|
const pending = new Map;
|
|
97
252
|
let token = storage.getItem(tokenKey);
|
|
@@ -103,7 +258,7 @@ async function createBrowserStorage(options) {
|
|
|
103
258
|
const available = () => !transitioning && storage.getItem(tokenKey) === token;
|
|
104
259
|
const key = (name) => `${prefix}account:${scope}:${name}`;
|
|
105
260
|
const snapshotKey = (name) => name === "app" ? `${prefix}app` : key(`snapshot:${name}`);
|
|
106
|
-
const keys = (base) => Array.from({ length:
|
|
261
|
+
const keys = (base, source = storage) => Array.from({ length: source.length }, (_, index) => source.key(index)).filter((value) => value !== null && value.startsWith(base));
|
|
107
262
|
const parse = (value) => {
|
|
108
263
|
if (value === null)
|
|
109
264
|
return null;
|
|
@@ -137,22 +292,22 @@ async function createBrowserStorage(options) {
|
|
|
137
292
|
const captured = capture();
|
|
138
293
|
const address = key(`storage:${name}`);
|
|
139
294
|
const json = value === null || value === undefined ? null : encode(value);
|
|
140
|
-
const write = () => {
|
|
295
|
+
const write = (source) => {
|
|
141
296
|
if (json === null)
|
|
142
|
-
|
|
297
|
+
source.removeItem(address);
|
|
143
298
|
else
|
|
144
|
-
|
|
299
|
+
source.setItem(address, json);
|
|
145
300
|
};
|
|
146
301
|
if (!lock) {
|
|
147
|
-
write();
|
|
302
|
+
write(storage);
|
|
148
303
|
return;
|
|
149
304
|
}
|
|
150
305
|
const entry = { json };
|
|
151
306
|
pending.set(address, entry);
|
|
152
|
-
exclusive(() => {
|
|
307
|
+
exclusive(() => checkpoint((source) => {
|
|
153
308
|
requireScope(captured);
|
|
154
|
-
write();
|
|
155
|
-
}).catch((error) => {
|
|
309
|
+
write(source);
|
|
310
|
+
})).catch((error) => {
|
|
156
311
|
if (error.code !== "STORAGE_SCOPE_CHANGED")
|
|
157
312
|
console.error("Browser storage write failed", error);
|
|
158
313
|
}).finally(() => {
|
|
@@ -172,7 +327,12 @@ async function createBrowserStorage(options) {
|
|
|
172
327
|
token = next;
|
|
173
328
|
pending.clear();
|
|
174
329
|
transition = (async () => {
|
|
175
|
-
await exclusive(() => {
|
|
330
|
+
await exclusive(async () => {
|
|
331
|
+
if (checkpoints)
|
|
332
|
+
await checkpoint((source) => {
|
|
333
|
+
for (const name of keys(`${prefix}account:${previous}:`, source))
|
|
334
|
+
source.removeItem(name);
|
|
335
|
+
});
|
|
176
336
|
for (const name of keys(`${prefix}account:${previous}:`))
|
|
177
337
|
storage.removeItem(name);
|
|
178
338
|
for (const name of options.accountKeys ?? []) {
|
|
@@ -195,7 +355,7 @@ async function createBrowserStorage(options) {
|
|
|
195
355
|
};
|
|
196
356
|
const secureKey = (name) => name === sessionTokenKey ? tokenKey : key(`credential:${name}`);
|
|
197
357
|
return {
|
|
198
|
-
atomicStorage
|
|
358
|
+
atomicStorage,
|
|
199
359
|
scope: () => scope,
|
|
200
360
|
accountChanged,
|
|
201
361
|
craft: {
|
|
@@ -246,9 +406,9 @@ async function createBrowserStorage(options) {
|
|
|
246
406
|
let json;
|
|
247
407
|
let expected;
|
|
248
408
|
if (wasAvailable && module === "Storage") {
|
|
249
|
-
if (method === "set" || method === "setIfAbsent" &&
|
|
409
|
+
if (method === "set" || method === "setIfAbsent" && atomicStorage)
|
|
250
410
|
json = encode(args[1]);
|
|
251
|
-
if (method === "compareAndSet" &&
|
|
411
|
+
if (method === "compareAndSet" && atomicStorage) {
|
|
252
412
|
expected = parse(encode(args[1]));
|
|
253
413
|
json = encode(args[2]);
|
|
254
414
|
}
|
|
@@ -257,43 +417,43 @@ async function createBrowserStorage(options) {
|
|
|
257
417
|
if (module === "Storage") {
|
|
258
418
|
if (!wasAvailable)
|
|
259
419
|
throw scopeChanged();
|
|
260
|
-
return exclusive(() => {
|
|
420
|
+
return exclusive(() => checkpoint((source) => {
|
|
261
421
|
requireScope(captured);
|
|
262
422
|
const address = `${prefix}account:${captured.scope}:storage:${name}`;
|
|
263
423
|
if (method === "get")
|
|
264
|
-
return parse(
|
|
424
|
+
return parse(source.getItem(address));
|
|
265
425
|
if (method === "set") {
|
|
266
|
-
|
|
426
|
+
source.setItem(address, json);
|
|
267
427
|
return true;
|
|
268
428
|
}
|
|
269
429
|
if (method === "remove") {
|
|
270
|
-
|
|
430
|
+
source.removeItem(address);
|
|
271
431
|
return true;
|
|
272
432
|
}
|
|
273
433
|
if (method === "keys")
|
|
274
|
-
return keys(key("storage:")).map((item) => item.slice(key("storage:").length));
|
|
434
|
+
return keys(key("storage:"), source).map((item) => item.slice(key("storage:").length));
|
|
275
435
|
if (method === "clear") {
|
|
276
|
-
for (const item of keys(key("storage:")))
|
|
277
|
-
|
|
436
|
+
for (const item of keys(key("storage:"), source))
|
|
437
|
+
source.removeItem(item);
|
|
278
438
|
return true;
|
|
279
439
|
}
|
|
280
440
|
if (method === "compareAndSet" || method === "setIfAbsent") {
|
|
281
|
-
if (!
|
|
282
|
-
throw Object.assign(new Error("Atomic browser storage requires Web Locks"), { code: method === "setIfAbsent" ? "STORAGE_SET_IF_ABSENT_UNSUPPORTED" : "STORAGE_COMPARE_AND_SET_UNSUPPORTED" });
|
|
283
|
-
const raw =
|
|
441
|
+
if (!atomicStorage)
|
|
442
|
+
throw Object.assign(new Error("Atomic browser storage requires transactional persistence and Web Locks"), { code: method === "setIfAbsent" ? "STORAGE_SET_IF_ABSENT_UNSUPPORTED" : "STORAGE_COMPARE_AND_SET_UNSUPPORTED" });
|
|
443
|
+
const raw = source.getItem(address);
|
|
284
444
|
if (method === "setIfAbsent") {
|
|
285
445
|
if (raw !== null)
|
|
286
446
|
return false;
|
|
287
|
-
|
|
447
|
+
source.setItem(address, json);
|
|
288
448
|
return true;
|
|
289
449
|
}
|
|
290
450
|
if (raw === null || !jsonEqual(parse(raw), expected))
|
|
291
451
|
return false;
|
|
292
|
-
|
|
452
|
+
source.setItem(address, json);
|
|
293
453
|
return true;
|
|
294
454
|
}
|
|
295
455
|
throw Object.assign(new Error(`Unsupported browser storage operation ${method}`), { code: "UNSUPPORTED" });
|
|
296
|
-
}, signal);
|
|
456
|
+
}, signal), signal);
|
|
297
457
|
}
|
|
298
458
|
if (module === "SecureStorage") {
|
|
299
459
|
if (!wasAvailable)
|