craft-native 0.0.262 → 0.0.272
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 +22 -0
- package/dist/android/templates/CraftNativeMutation.kt.template +2 -0
- package/dist/android/templates/MainActivityNative.kt.template +131 -10
- package/dist/browser/lifecycle.d.ts +7 -0
- package/dist/browser.js +126 -24
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +19 -3
- package/dist/ios/templates/CraftHybrid.swift +20 -0
- package/dist/ios/templates/CraftNativeActions.swift +108 -21
- package/dist/ios/templates/CraftNativeFlatList.swift +156 -16
- package/dist/ios/templates/CraftNativeMutation.swift +3 -1
- package/dist/ios/templates/CraftNativeScreen.swift +179 -15
- package/dist/macos/templates/CraftNativeMutation.swift +3 -1
- package/package.json +1 -1
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`.
|
|
@@ -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
|
|
499
|
-
|
|
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.
|
|
504
|
-
val command =
|
|
505
|
-
fun number(offset: Int): Float =
|
|
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.
|
|
508
|
-
1 -> if (index + 2 < geometry.
|
|
509
|
-
2 -> if (index + 6 < geometry.
|
|
510
|
-
3 -> if (index + 4 < geometry.
|
|
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
|
-
|
|
418
|
-
|
|
466
|
+
soundCleanup.get(sound)?.();
|
|
467
|
+
soundCleanup.delete(sound);
|
|
419
468
|
sound.pause();
|
|
420
469
|
sound.removeAttribute("src");
|
|
421
|
-
sounds.
|
|
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
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
const
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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.
|
|
2845
|
+
var version = "0.0.272";
|
|
2846
2846
|
|
|
2847
2847
|
// bin/cli.ts
|
|
2848
2848
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
|
@@ -797,10 +797,21 @@ final class CraftChrome: ObservableObject {
|
|
|
797
797
|
tabAccessory = next
|
|
798
798
|
}
|
|
799
799
|
|
|
800
|
-
/// A
|
|
801
|
-
|
|
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
|
-
|
|
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 {
|
|
@@ -240,8 +240,10 @@ enum CraftHybridKeychain {
|
|
|
240
240
|
static let nativeService = "dev.craft.native.secure"
|
|
241
241
|
|
|
242
242
|
static func set(_ key: String, value: String?) {
|
|
243
|
+
defer { CraftKeychain.forget(account: key) }
|
|
243
244
|
// Every item for the account, whatever its service.
|
|
244
245
|
SecItemDelete([kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key] as CFDictionary)
|
|
246
|
+
CraftKeychain.forget(account: key)
|
|
245
247
|
guard let value else { return }
|
|
246
248
|
for service in [nil, nativeService] {
|
|
247
249
|
var add: [String: Any] = [
|
|
@@ -2034,6 +2036,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
2034
2036
|
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
|
|
2035
2037
|
lastNavigation = CACurrentMediaTime()
|
|
2036
2038
|
CraftFramePacing.shared.end("navigation")
|
|
2039
|
+
// A page shows no native accessory; one a screen gave up is taken off.
|
|
2040
|
+
if viewController is CraftHybridWebSlot { CraftChrome.shared.clearUnclaimedTabAccessory() }
|
|
2037
2041
|
let key = ObjectIdentifier(navigationController)
|
|
2038
2042
|
let before = shownStacks[key] ?? []
|
|
2039
2043
|
let now = navigationController.viewControllers
|
|
@@ -2301,6 +2305,22 @@ final class CraftFramePacing: NSObject {
|
|
|
2301
2305
|
return result
|
|
2302
2306
|
}
|
|
2303
2307
|
|
|
2308
|
+
/// One piece of the screens' JavaScript (CraftNativeScriptThread): how
|
|
2309
|
+
/// long it waited behind other screens' work, and how long it ran. The
|
|
2310
|
+
/// screens share one serial script thread, so a screen in front can sit
|
|
2311
|
+
/// behind hidden tabs reloading; `waited` is where that shows.
|
|
2312
|
+
func scriptWork(_ name: String, waited: CFTimeInterval, ran: CFTimeInterval) {
|
|
2313
|
+
guard enabled else { return }
|
|
2314
|
+
write(String(format: "script %@ waited=%.1fms ran=%.1fms", name, waited * 1000, ran * 1000))
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
/// A capability a screen asked for (a request, the keychain), from the
|
|
2318
|
+
/// ask to the answer. Logged from 4ms, as script work is.
|
|
2319
|
+
func capability(_ name: String, took: CFTimeInterval) {
|
|
2320
|
+
guard enabled, took >= 0.004 else { return }
|
|
2321
|
+
write(String(format: "capability %@ took=%.1fms", name, took * 1000))
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2304
2324
|
/// Each line to the log and to `Library/Caches/craft-frame-pacing.log`
|
|
2305
2325
|
/// in the app's container, which a test run reads back whole (a
|
|
2306
2326
|
/// simulator's unified log can drop an app's lines under load). Written
|