craft-native 0.0.262 → 0.0.276

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 CHANGED
@@ -646,3 +646,25 @@ An explicit discard may ACK the captured old ID only after a fresh committed
646
646
  native read proves that its previously registered intent was deleted or
647
647
  replaced. A cached missing draft or an unsuccessful initial registration is
648
648
  not evidence of deletion.
649
+
650
+ ### Browser lifecycle
651
+
652
+ `createBrowserPlatform()` from `craft-native/browser` supplies
653
+ `craft.lifecycle.getState()` and `onStateChange(callback)` (also `onChange`).
654
+ States are `active` and `background`. The host emits the native-compatible
655
+ `APP_STATE { state }` event on visibility changes and page cache navigation,
656
+ coalesces duplicate transitions, and releases listeners in `dispose()`.
657
+ This lets browser simulations checkpoint on backgrounding with the same contract
658
+ as a native shell. Storage failures must still be handled by the caller.
659
+
660
+ Await `platform.call('Storage', 'set', [key, value], signal)` before reporting
661
+ a saved checkpoint. `craft.storage.setSync` updates the immediate UI cache and
662
+ queues its write when Web Locks are available; it cannot acknowledge durability
663
+ or propagate a later quota failure. Background events are an opportunity to
664
+ checkpoint, but browsers can terminate a page before queued work finishes, so
665
+ also save periodically during play.
666
+
667
+ Browser audio assigns one owner per sound ID. A newer play or stop invalidates
668
+ pending media resolution for that ID; stopping all sounds, changing accounts,
669
+ aborting a call or disposing the host also releases active audio. Superseded
670
+ play calls reject with `AbortError`.
@@ -16,6 +16,8 @@ internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMa
16
16
  private var pendingChildren = JSONArray()
17
17
  private var styleKey: String? = null
18
18
  private var styleVersion = 0
19
+ /** The prefix of a style key that is a vector style's URL, not raster style JSON. */
20
+ private val vectorStylePrefix = "uri:"
19
21
  private var readyStyle: Style? = null
20
22
  private var initialCameraKey: String? = null
21
23
  private var reportedRegion: LatLngBounds? = null
@@ -146,7 +148,10 @@ internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMa
146
148
  styleKey = nextStyle
147
149
  readyStyle = null
148
150
  val version = ++styleVersion
149
- nativeMap.setStyle(Style.Builder().fromJson(nextStyle)) { loaded ->
151
+ // A vector style (the standard basemap) loads from its URL; the
152
+ // raster ones are composed here as style JSON.
153
+ val builder = if (nextStyle.startsWith(vectorStylePrefix)) Style.Builder().fromUri(nextStyle.removePrefix(vectorStylePrefix)) else Style.Builder().fromJson(nextStyle)
154
+ nativeMap.setStyle(builder) { loaded ->
150
155
  if (!destroyed && version == styleVersion) {
151
156
  readyStyle = loaded
152
157
  installOverlays(loaded)
@@ -183,6 +188,20 @@ internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMa
183
188
  }
184
189
 
185
190
  private fun basemapStyle(): String {
191
+ // `basemap`: the named presets, drawn as well as each can be here.
192
+ // Standard is OpenFreeMap's vector style - sharp at any density, no
193
+ // key - where it was raster tiles from tile.openstreetmap.org, whose
194
+ // policy does not allow an app's traffic. Outdoors is topography with
195
+ // contours; satellite and hybrid are Esri imagery.
196
+ when (pendingProps.optString("basemap", "").lowercase()) {
197
+ "standard" -> {
198
+ attribution.text = "OpenFreeMap © OpenMapTiles · Data © OpenStreetMap contributors"
199
+ return vectorStylePrefix + "https://tiles.openfreemap.org/styles/liberty"
200
+ }
201
+ "outdoors" -> return rasterStyle("https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}", "Topography: Esri, USGS, NOAA, OpenStreetMap contributors", labels = false)
202
+ "satellite" -> return rasterStyle("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", "Imagery © Esri, Maxar, Earthstar Geographics, and the GIS User Community", labels = false)
203
+ "hybrid" -> return rasterStyle("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", "Imagery © Esri, Maxar, Earthstar Geographics, and the GIS User Community", labels = true)
204
+ }
186
205
  val custom = pendingProps.optJSONObject("tileOverlay")
187
206
  val template = custom?.optString("urlTemplate").orEmpty()
188
207
  val credit = custom?.optString("attribution").orEmpty()
@@ -212,6 +231,18 @@ internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMa
212
231
  return JSONObject().put("version", 8).put("sources", sources).put("layers", layers).toString()
213
232
  }
214
233
 
234
+ private fun rasterStyle(url: String, credit: String, labels: Boolean): String {
235
+ val sources = JSONObject().put("basemap", rasterSource(url, credit))
236
+ val layers = JSONArray().put(JSONObject().put("id", "basemap").put("type", "raster").put("source", "basemap"))
237
+ attribution.text = credit
238
+ if (labels) {
239
+ sources.put("labels", rasterSource("https://services.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}", "Labels © Esri"))
240
+ layers.put(JSONObject().put("id", "labels").put("type", "raster").put("source", "labels"))
241
+ attribution.text = "$credit · Labels © Esri"
242
+ }
243
+ return JSONObject().put("version", 8).put("sources", sources).put("layers", layers).toString()
244
+ }
245
+
215
246
  private fun rasterSource(url: String, credit: String): JSONObject = JSONObject()
216
247
  .put("type", "raster").put("tiles", JSONArray().put(url)).put("tileSize", 256).put("maxzoom", 19).put("attribution", credit)
217
248
 
@@ -309,6 +309,8 @@ class CraftNativeMutationDocument {
309
309
  "View", "SafeAreaView", "ScrollView", "KeyboardAvoidingView", "Modal", "Pressable",
310
310
  "TouchableOpacity", "TouchableHighlight", "ImageBackground", "FlatList", "SectionList",
311
311
  "Fragment", "Svg", "G", "Map",
312
+ // A shape holds its `Animate`: SMIL keyframes.
313
+ "Path", "Line", "Rect", "Circle", "Ellipse", "Polyline", "Polygon",
312
314
  )
313
315
  }
314
316
 
@@ -351,10 +351,131 @@ private class CraftNativeSvgView(context: Context) : View(context) {
351
351
  var drawing: JSONObject = JSONObject()
352
352
  set(value) {
353
353
  field = value
354
+ animates = hasAnimation(value.optJSONArray("children"))
354
355
  invalidate()
355
356
  requestLayout()
357
+ updateFrames()
356
358
  }
357
359
 
360
+ /** Whether a shape carries keyframes (SMIL `<animate attributeName="d">`, stx's `Animate`). */
361
+ private var animates = false
362
+ /** When this drawing's repetition began (System.nanoTime), 0 while still. */
363
+ private var animationStart = 0L
364
+ private var frameScheduled = false
365
+ private val frameCallback = android.view.Choreographer.FrameCallback { nextFrame() }
366
+
367
+ /** Moving now: shown, and animations are on (off is Android's reduced motion). */
368
+ val isAnimating: Boolean
369
+ get() = animates && isAttachedToWindow && isShown && ValueAnimator.areAnimatorsEnabled()
370
+
371
+ override fun onAttachedToWindow() {
372
+ super.onAttachedToWindow()
373
+ updateFrames()
374
+ }
375
+
376
+ override fun onDetachedFromWindow() {
377
+ super.onDetachedFromWindow()
378
+ updateFrames()
379
+ }
380
+
381
+ override fun onVisibilityChanged(changedView: View, visibility: Int) {
382
+ super.onVisibilityChanged(changedView, visibility)
383
+ updateFrames()
384
+ }
385
+
386
+ /** One frame at a time from the display (Choreographer) while moving; none while still or away. */
387
+ private fun updateFrames() {
388
+ if (isAnimating) {
389
+ if (animationStart == 0L) animationStart = System.nanoTime()
390
+ if (!frameScheduled) {
391
+ frameScheduled = true
392
+ android.view.Choreographer.getInstance().postFrameCallback(frameCallback)
393
+ }
394
+ } else {
395
+ animationStart = 0L
396
+ if (frameScheduled) {
397
+ frameScheduled = false
398
+ android.view.Choreographer.getInstance().removeFrameCallback(frameCallback)
399
+ }
400
+ }
401
+ }
402
+
403
+ private fun nextFrame() {
404
+ frameScheduled = false
405
+ if (!isAnimating) return updateFrames()
406
+ // Drawn only while some of it is on screen; a row scrolled away keeps its time.
407
+ if (getLocalVisibleRect(android.graphics.Rect())) invalidate()
408
+ frameScheduled = true
409
+ android.view.Choreographer.getInstance().postFrameCallback(frameCallback)
410
+ }
411
+
412
+ private fun hasAnimation(children: JSONArray?): Boolean {
413
+ if (children == null) return false
414
+ return (0 until children.length()).any { index ->
415
+ val node = children.optJSONObject(index) ?: return@any false
416
+ if (node.optString("type") == "Animate") node.optJSONObject("props")?.has("frames") == true
417
+ else hasAnimation(node.optJSONArray("children"))
418
+ }
419
+ }
420
+
421
+ /** A shape's outline now: between its keyframes while moving, else its own. */
422
+ private fun outline(node: JSONObject): List<Float>? {
423
+ if (isAnimating && animationStart != 0L) {
424
+ val children = node.optJSONArray("children")
425
+ val animate = children?.let { list -> (0 until list.length()).mapNotNull { list.optJSONObject(it) }.firstOrNull { it.optString("type") == "Animate" } }
426
+ animate?.optJSONObject("props")?.let { keyframe(it, (System.nanoTime() - animationStart) / 1e9) }?.let { return it }
427
+ }
428
+ val geometry = node.optJSONObject("props")?.optJSONArray("geometry") ?: return null
429
+ return (0 until geometry.length()).map { craftNativeNumber(geometry.opt(it))?.toFloat() ?: Float.NaN }
430
+ }
431
+
432
+ companion object {
433
+ /**
434
+ * The outline `elapsed` seconds into an `Animate`'s repetitions, between
435
+ * the keyframes either side of now (SMIL's linear `calcMode`); null when
436
+ * it does not animate path data or its repetitions are over.
437
+ */
438
+ fun keyframe(props: JSONObject, elapsed: Double): List<Float>? {
439
+ if (props.optString("attributeName") != "d" || elapsed < 0) return null
440
+ val duration = craftNativeNumber(props.opt("duration"))?.takeIf { it.isFinite() && it > 0 } ?: return null
441
+ val raw = props.optJSONArray("frames") ?: return null
442
+ val frames = (0 until raw.length()).mapNotNull { index ->
443
+ raw.optJSONArray(index)?.let { frame -> (0 until frame.length()).map { craftNativeNumber(frame.opt(it))?.toFloat() ?: Float.NaN } }
444
+ }
445
+ if (frames.size < 2 || frames.size != raw.length()) return null
446
+ craftNativeNumber(props.opt("repeatCount"))?.let { if (it.isFinite() && elapsed >= it * duration) return null }
447
+ val given = props.optJSONArray("frameTimes")?.let { times -> (0 until times.length()).mapNotNull { craftNativeNumber(times.opt(it)) } }.orEmpty()
448
+ val times = if (given.size == frames.size) given else frames.indices.map { it.toDouble() / (frames.size - 1) }
449
+ val phase = (elapsed % duration) / duration
450
+ var index = 0
451
+ while (index < frames.size - 2 && times[index + 1] <= phase) index++
452
+ val span = times[index + 1] - times[index]
453
+ val share = if (span > 0) ((phase - times[index]) / span).coerceIn(0.0, 1.0).toFloat() else 0f
454
+ return interpolate(frames[index], frames[index + 1], share)
455
+ }
456
+
457
+ /** Two outlines with the same commands, a share of the way between; the first when they differ. */
458
+ fun interpolate(from: List<Float>, to: List<Float>, share: Float): List<Float> {
459
+ if (from.size != to.size || share <= 0f) return from
460
+ val out = from.toMutableList()
461
+ var index = 0
462
+ while (index < from.size) {
463
+ if (to[index] != from[index]) return from
464
+ val count = when (from[index].toInt()) {
465
+ 0, 1 -> 2
466
+ 2 -> 6
467
+ 3 -> 4
468
+ 4 -> 0
469
+ else -> return from
470
+ }
471
+ if (index + count >= from.size) return from
472
+ for (offset in 1..count) out[index + offset] = from[index + offset] + (to[index + offset] - from[index + offset]) * share
473
+ index += count + 1
474
+ }
475
+ return out
476
+ }
477
+ }
478
+
358
479
  private data class SvgPaint(
359
480
  val fill: String? = "#000000",
360
481
  val stroke: String? = null,
@@ -489,25 +610,25 @@ private class CraftNativeSvgView(context: Context) : View(context) {
489
610
  when (node.optString("type")) {
490
611
  "G" -> drawNodes(canvas, node.optJSONArray("children") ?: JSONArray(), paint)
491
612
  "SvgText" -> drawText(canvas, node, paint)
613
+ "Animate" -> Unit
492
614
  else -> drawPath(canvas, node, paint)
493
615
  }
494
616
  }
495
617
  }
496
618
 
497
619
  private fun drawPath(canvas: Canvas, node: JSONObject, style: SvgPaint) {
498
- val props = node.optJSONObject("props") ?: JSONObject()
499
- val geometry = props.optJSONArray("geometry") ?: return
500
- if ((0 until geometry.length()).any { craftNativeNumber(geometry.opt(it))?.toFloat()?.isFinite() != true }) return
620
+ val geometry = outline(node) ?: return
621
+ if (geometry.any { !it.isFinite() }) return
501
622
  val path = Path()
502
623
  var index = 0
503
- while (index < geometry.length()) {
504
- val command = craftNativeNumber(geometry.opt(index))?.toInt() ?: break
505
- fun number(offset: Int): Float = craftNativeNumber(geometry.opt(index + offset))?.toFloat() ?: Float.NaN
624
+ while (index < geometry.size) {
625
+ val command = geometry[index].toInt()
626
+ fun number(offset: Int): Float = geometry[index + offset]
506
627
  when (command) {
507
- 0 -> if (index + 2 < geometry.length()) { path.moveTo(number(1), number(2)); index += 3 } else break
508
- 1 -> if (index + 2 < geometry.length()) { path.lineTo(number(1), number(2)); index += 3 } else break
509
- 2 -> if (index + 6 < geometry.length()) { path.cubicTo(number(1), number(2), number(3), number(4), number(5), number(6)); index += 7 } else break
510
- 3 -> if (index + 4 < geometry.length()) { path.quadTo(number(1), number(2), number(3), number(4)); index += 5 } else break
628
+ 0 -> if (index + 2 < geometry.size) { path.moveTo(number(1), number(2)); index += 3 } else break
629
+ 1 -> if (index + 2 < geometry.size) { path.lineTo(number(1), number(2)); index += 3 } else break
630
+ 2 -> if (index + 6 < geometry.size) { path.cubicTo(number(1), number(2), number(3), number(4), number(5), number(6)); index += 7 } else break
631
+ 3 -> if (index + 4 < geometry.size) { path.quadTo(number(1), number(2), number(3), number(4)); index += 5 } else break
511
632
  4 -> { path.close(); index += 1 }
512
633
  else -> break
513
634
  }
@@ -0,0 +1,7 @@
1
+ export type BrowserAppState = 'active' | 'background';
2
+ /** Browser lifecycle shares the native APP_STATE contract, including bfcache navigation. */
3
+ export declare function createBrowserLifecycle(page: Pick<Document, 'hidden' | 'addEventListener' | 'removeEventListener'>, host: Pick<Window, 'addEventListener' | 'removeEventListener'>, changed: (state: BrowserAppState) => void): {
4
+ getState: () => BrowserAppState;
5
+ onStateChange(callback: (state: BrowserAppState) => void): () => void;
6
+ dispose(): void;
7
+ };
package/dist/browser.js CHANGED
@@ -1,3 +1,50 @@
1
+ // src/browser/lifecycle.ts
2
+ function createBrowserLifecycle(page, host, changed) {
3
+ let state = page.hidden ? "background" : "active";
4
+ const listeners = new Set;
5
+ let disposed = false;
6
+ const set = (next) => {
7
+ if (disposed || state === next)
8
+ return;
9
+ state = next;
10
+ changed(state);
11
+ for (const listener of [...listeners]) {
12
+ try {
13
+ listener(state);
14
+ } catch (error) {
15
+ console.error("craft.lifecycle listener failed", error);
16
+ }
17
+ }
18
+ };
19
+ const visibility = () => set(page.hidden ? "background" : "active");
20
+ const hide = () => set("background");
21
+ page.addEventListener("visibilitychange", visibility);
22
+ host.addEventListener("pagehide", hide);
23
+ host.addEventListener("pageshow", visibility);
24
+ return {
25
+ getState: () => state,
26
+ onStateChange(callback) {
27
+ if (typeof callback !== "function")
28
+ throw new TypeError("lifecycle.onStateChange needs a function");
29
+ if (disposed)
30
+ return () => {};
31
+ listeners.add(callback);
32
+ return () => {
33
+ listeners.delete(callback);
34
+ };
35
+ },
36
+ dispose() {
37
+ if (disposed)
38
+ return;
39
+ disposed = true;
40
+ page.removeEventListener("visibilitychange", visibility);
41
+ host.removeEventListener("pagehide", hide);
42
+ host.removeEventListener("pageshow", visibility);
43
+ listeners.clear();
44
+ }
45
+ };
46
+ }
47
+
1
48
  // src/bridge/ids.ts
2
49
  function secureUUID() {
3
50
  const c = globalThis.crypto;
@@ -377,6 +424,8 @@ async function createBrowserPlatform(options = {}) {
377
424
  const files = new Map;
378
425
  const mediaURLs = new Map;
379
426
  const sounds = new Map;
427
+ const soundRequests = new Map;
428
+ const soundCleanup = new Map;
380
429
  const fades = new Map;
381
430
  const cancelFade = (sound) => {
382
431
  const fade = fades.get(sound);
@@ -414,11 +463,12 @@ async function createBrowserPlatform(options = {}) {
414
463
  if (!sound)
415
464
  return;
416
465
  await fade(sound, 0, sound.paused ? 0 : milliseconds);
417
- if (sounds.get(id) !== sound)
418
- return;
466
+ soundCleanup.get(sound)?.();
467
+ soundCleanup.delete(sound);
419
468
  sound.pause();
420
469
  sound.removeAttribute("src");
421
- sounds.delete(id);
470
+ if (sounds.get(id) === sound)
471
+ sounds.delete(id);
422
472
  };
423
473
  const lifetime = new AbortController;
424
474
  let account = new AbortController;
@@ -454,6 +504,7 @@ async function createBrowserPlatform(options = {}) {
454
504
  const resetAccount = async () => {
455
505
  const previousCache = `${namespace}:media:${mediaScope}`;
456
506
  account.abort();
507
+ soundRequests.clear();
457
508
  account = new AbortController;
458
509
  for (const path of files.keys())
459
510
  URL.revokeObjectURL(path);
@@ -462,10 +513,12 @@ async function createBrowserPlatform(options = {}) {
462
513
  files.clear();
463
514
  mediaURLs.clear();
464
515
  for (const audio of sounds.values()) {
516
+ soundCleanup.get(audio)?.();
465
517
  cancelFade(audio);
466
518
  audio.pause();
467
519
  audio.src = "";
468
520
  }
521
+ soundCleanup.clear();
469
522
  sounds.clear();
470
523
  await persistence.accountChanged();
471
524
  mediaScope = persistence.scope();
@@ -480,7 +533,10 @@ async function createBrowserPlatform(options = {}) {
480
533
  });
481
534
  };
482
535
  window.addEventListener("storage", storageEvent);
483
- const capabilityNames = ["storage", "secureStorage", "lifecycle", "dialog", "browser", "linking", "files", "share", "device"];
536
+ const lifecycle = typeof document !== "undefined" ? createBrowserLifecycle(document, window, (state) => emit("APP_STATE", { state })) : null;
537
+ const capabilityNames = ["storage", "secureStorage", "dialog", "browser", "linking", "files", "share", "device"];
538
+ if (lifecycle)
539
+ capabilityNames.push("lifecycle");
484
540
  if (persistence.atomicStorage)
485
541
  capabilityNames.push("storageCompareAndSet", "storageSetIfAbsent");
486
542
  if (typeof globalThis.crypto?.randomUUID === "function" || typeof globalThis.crypto?.getRandomValues === "function")
@@ -500,6 +556,7 @@ async function createBrowserPlatform(options = {}) {
500
556
  const platform = {
501
557
  craft: {
502
558
  ...persistence.craft,
559
+ ...lifecycle ? { lifecycle: { getState: lifecycle.getState, onStateChange: lifecycle.onStateChange, onChange: lifecycle.onStateChange } } : {},
503
560
  session: {
504
561
  key: sessionTokenKey,
505
562
  token: () => persistence.craft.secureStorage.getSync(sessionTokenKey),
@@ -755,29 +812,74 @@ async function createBrowserPlatform(options = {}) {
755
812
  const id = String(args[0]);
756
813
  const sound = sounds.get(id);
757
814
  if (method === "play") {
758
- await stopSound(id);
759
- const audio = new Audio(await platform.resolveMedia(String(args[1])));
760
- const settings = object(args[2]);
761
- audio.loop = Boolean(settings.loop);
762
- const volume = Math.max(0, Math.min(1, Number(settings.volume ?? 1)));
763
- audio.volume = Number(settings.fadeIn) > 0 ? 0 : volume;
764
- audio.onended = () => {
765
- sounds.delete(id);
766
- emit("SOUND_EVENT", { id, event: "ended" });
815
+ const request = {};
816
+ soundRequests.set(id, request);
817
+ const current = () => {
818
+ signal.throwIfAborted();
819
+ if (soundRequests.get(id) !== request)
820
+ throw new DOMException("Playback replaced", "AbortError");
767
821
  };
768
- audio.onerror = () => {
769
- sounds.delete(id);
770
- emit("SOUND_EVENT", { id, event: "error", message: audio.error?.message });
822
+ let audio;
823
+ const release = () => {
824
+ if (!audio)
825
+ return;
826
+ signal.removeEventListener("abort", release);
827
+ soundCleanup.delete(audio);
828
+ cancelFade(audio);
829
+ audio.pause();
830
+ audio.removeAttribute("src");
831
+ if (sounds.get(id) === audio)
832
+ sounds.delete(id);
771
833
  };
772
- sounds.set(id, audio);
773
- await audio.play();
774
- fade(audio, volume, Number(settings.fadeIn ?? 0));
775
- return true;
834
+ try {
835
+ await stopSound(id);
836
+ current();
837
+ const url = await platform.resolveMedia(String(args[1]));
838
+ current();
839
+ audio = new Audio(url);
840
+ const settings = object(args[2]);
841
+ audio.loop = Boolean(settings.loop);
842
+ const volume = Math.max(0, Math.min(1, Number(settings.volume ?? 1)));
843
+ audio.volume = Number(settings.fadeIn) > 0 ? 0 : volume;
844
+ audio.onended = () => {
845
+ release();
846
+ if (soundRequests.get(id) === request) {
847
+ soundRequests.delete(id);
848
+ emit("SOUND_EVENT", { id, event: "ended" });
849
+ }
850
+ };
851
+ audio.onerror = () => {
852
+ const message = audio?.error?.message;
853
+ release();
854
+ if (soundRequests.get(id) === request) {
855
+ soundRequests.delete(id);
856
+ emit("SOUND_EVENT", { id, event: "error", message });
857
+ }
858
+ };
859
+ sounds.set(id, audio);
860
+ soundCleanup.set(audio, release);
861
+ signal.addEventListener("abort", release, { once: true });
862
+ await audio.play();
863
+ current();
864
+ fade(audio, volume, Number(settings.fadeIn ?? 0));
865
+ return true;
866
+ } catch (error) {
867
+ release();
868
+ if (soundRequests.get(id) === request)
869
+ soundRequests.delete(id);
870
+ throw error;
871
+ }
776
872
  }
777
873
  if (method === "stopAll") {
874
+ soundRequests.clear();
778
875
  await Promise.all([...sounds.keys()].map((key) => stopSound(key, Number(first.fadeOut ?? 0))));
779
876
  return true;
780
877
  }
878
+ if (method === "stop") {
879
+ soundRequests.delete(id);
880
+ await stopSound(id, Number(object(args[1]).fadeOut ?? 0));
881
+ return Boolean(sound);
882
+ }
781
883
  if (!sound)
782
884
  return false;
783
885
  if (method === "pause") {
@@ -788,10 +890,6 @@ async function createBrowserPlatform(options = {}) {
788
890
  await sound.play();
789
891
  return true;
790
892
  }
791
- if (method === "stop") {
792
- await stopSound(id, Number(object(args[1]).fadeOut ?? 0));
793
- return true;
794
- }
795
893
  if (method === "setVolume") {
796
894
  await fade(sound, Number(args[1]), Number(object(args[2]).fade ?? 0));
797
895
  return true;
@@ -873,6 +971,8 @@ async function createBrowserPlatform(options = {}) {
873
971
  if (disposed)
874
972
  return;
875
973
  disposed = true;
974
+ soundRequests.clear();
975
+ lifecycle?.dispose();
876
976
  window.removeEventListener("storage", storageEvent);
877
977
  lifetime.abort();
878
978
  account.abort();
@@ -883,10 +983,12 @@ async function createBrowserPlatform(options = {}) {
883
983
  files.clear();
884
984
  mediaURLs.clear();
885
985
  for (const audio of sounds.values()) {
986
+ soundCleanup.get(audio)?.();
886
987
  cancelFade(audio);
887
988
  audio.pause();
888
989
  audio.src = "";
889
990
  }
991
+ soundCleanup.clear();
890
992
  sounds.clear();
891
993
  wakeLock?.release();
892
994
  listeners.clear();
package/dist/cli.js CHANGED
@@ -2842,7 +2842,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
2842
2842
  `);
2843
2843
  }
2844
2844
  // package.json
2845
- var version = "0.0.262";
2845
+ var version = "0.0.276";
2846
2846
 
2847
2847
  // bin/cli.ts
2848
2848
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -373,7 +373,7 @@ function craftBinaryNotFoundMessage(triedPath) {
373
373
  `);
374
374
  }
375
375
  // package.json
376
- var version = "0.0.262";
376
+ var version = "0.0.276";
377
377
 
378
378
  // src/sdk-version.ts
379
379
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.262";
120
+ var version = "0.0.276";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -797,10 +797,21 @@ final class CraftChrome: ObservableObject {
797
797
  tabAccessory = next
798
798
  }
799
799
 
800
- /// A retained or prepared screen cannot clear another screen's chrome.
801
- func clearTabAccessory(ownedBy owner: AnyObject) {
800
+ /// A screen going away gives its accessory up without taking it off the
801
+ /// bar (a retained or prepared screen cannot clear another's): the screen coming in shows it until it says what it wants. Taken
802
+ /// off at once, the pill stretched across the bar and back while the
803
+ /// next screen (Messages, opened from the accessory; a tab opened for the
804
+ /// first time) started up and set the same accessory again.
805
+ func releaseTabAccessory(ownedBy owner: AnyObject) {
802
806
  guard tabAccessoryOwner === owner else { return }
803
- updateTabAccessory(nil)
807
+ tabAccessoryOwner = nil
808
+ }
809
+
810
+ /// Takes off an accessory nobody claims any more: the screen now showing
811
+ /// drew without asking for one, or is a page that never does.
812
+ func clearUnclaimedTabAccessory() {
813
+ guard tabAccessoryOwner == nil, tabAccessory != nil else { return }
814
+ tabAccessory = nil
804
815
  }
805
816
 
806
817
  func setHiddenByScreen(_ hidden: Bool) {
@@ -6347,6 +6358,7 @@ struct CraftWebView: UIViewRepresentable {
6347
6358
 
6348
6359
  SecItemDelete(query as CFDictionary)
6349
6360
  let status = SecItemAdd(query as CFDictionary, nil)
6361
+ CraftKeychain.forget(account: key)
6350
6362
  return status == errSecSuccess
6351
6363
  }
6352
6364
 
@@ -6373,12 +6385,14 @@ struct CraftWebView: UIViewRepresentable {
6373
6385
  kSecAttrAccount as String: key
6374
6386
  ]
6375
6387
  let status = SecItemDelete(query as CFDictionary)
6388
+ CraftKeychain.forget(account: key)
6376
6389
  return status == errSecSuccess || status == errSecItemNotFound
6377
6390
  }
6378
6391
 
6379
6392
  private func secureClear() -> Bool {
6380
6393
  let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword]
6381
6394
  let status = SecItemDelete(query as CFDictionary)
6395
+ CraftKeychain.forget(account: nil)
6382
6396
  return status == errSecSuccess || status == errSecItemNotFound
6383
6397
  }
6384
6398
 
@@ -8259,6 +8273,7 @@ struct CraftWebView: UIViewRepresentable {
8259
8273
  SecItemDelete(query as CFDictionary)
8260
8274
 
8261
8275
  let status = SecItemAdd(query as CFDictionary, nil)
8276
+ CraftKeychain.forget(account: key)
8262
8277
  if status == errSecSuccess {
8263
8278
  resolveCallback(callbackId, result: ["success": true, "key": key])
8264
8279
  } else {
@@ -8301,6 +8316,7 @@ struct CraftWebView: UIViewRepresentable {
8301
8316
  }
8302
8317
 
8303
8318
  let status = SecItemDelete(query as CFDictionary)
8319
+ CraftKeychain.forget(account: key)
8304
8320
  if status == errSecSuccess || status == errSecItemNotFound {
8305
8321
  resolveCallback(callbackId, result: ["success": true, "key": key])
8306
8322
  } else {