craft-native 0.0.487 → 0.0.491
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/src/index.d.ts +28 -0
- package/dist/android/src/index.js +15 -0
- package/dist/android/templates/CraftNativeServices.kt.template +10 -1
- package/dist/android/templates/CraftNativeShell.kt.template +282 -8
- package/dist/android/templates/MainActivityNative.kt.template +166 -28
- package/dist/cli.js +6 -3
- package/dist/index.cjs +6 -3
- package/dist/index.js +6 -3
- package/dist/ios/src/index.d.ts +42 -1
- package/dist/ios/src/index.js +15 -0
- package/dist/ios/templates/CraftApp.swift +30 -0
- package/dist/ios/templates/CraftHybrid.swift +49 -0
- package/dist/ios/templates/CraftNativeActions.swift +9 -1
- package/dist/ios/templates/CraftNativeScreen.swift +213 -28
- package/dist/ios/templates/CraftNativeServices.swift +35 -0
- package/dist/ios/templates/CraftVoiceRecording.swift +17 -1
- package/dist/ios/templates/CraftWebAudio.swift +77 -12
- package/dist/ios/templates/testing/DriverTests.swift.template +31 -1
- package/dist/mobile.js +2 -2
- package/dist/package.d.ts +5 -0
- package/package.json +1 -1
|
@@ -149,6 +149,55 @@ private fun craftNativeBoolean(value: Any?, fallback: Boolean = false): Boolean
|
|
|
149
149
|
|
|
150
150
|
private fun craftNativeInt(value: Any?, fallback: Int): Int = (value as? Number)?.toInt() ?: fallback
|
|
151
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Where a field's text and hint begin, vertically.
|
|
154
|
+
*
|
|
155
|
+
* The platform's `Widget.EditText` carries `gravity = center_vertical`, and
|
|
156
|
+
* nothing between it and this app overrides it: `Theme.CraftApp` inherits
|
|
157
|
+
* `Theme.Material3.DayNight.NoActionBar`, no Material3 theme binds
|
|
158
|
+
* `android:editTextStyle`, and AppCompat binds it to
|
|
159
|
+
* `Widget.AppCompat.EditText`, whose chain reaches `android:Widget.EditText`
|
|
160
|
+
* without touching gravity. A single-line field is never taller than its
|
|
161
|
+
* text, so the centring cannot show; a field given a `minHeight` drew its
|
|
162
|
+
* first line — and its hint — halfway down the box, where iOS and the web
|
|
163
|
+
* both start at the top (#502).
|
|
164
|
+
*
|
|
165
|
+
* Only the vertical bits are replaced. The horizontal ones are left exactly
|
|
166
|
+
* as they were, because `textAlign` resolves through `textAlignment`, whose
|
|
167
|
+
* default of `TEXT_ALIGNMENT_INHERIT` can still fall back to this gravity.
|
|
168
|
+
*/
|
|
169
|
+
private fun craftNativeVerticalGravity(current: Int, multiline: Boolean): Int =
|
|
170
|
+
(current and Gravity.VERTICAL_GRAVITY_MASK.inv()) or (if (multiline) Gravity.TOP else Gravity.CENTER_VERTICAL)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A scroll event's geometry in density-independent units.
|
|
175
|
+
*
|
|
176
|
+
* UIKit reports `contentOffset`, `contentSize` and `layoutMeasurement` in
|
|
177
|
+
* points, and this host's own `emitLayoutEvent` already divides a View's
|
|
178
|
+
* pixels by the display density. These three were the exception: they shipped
|
|
179
|
+
* raw pixels, so the same scroll handler saw values three times larger on a
|
|
180
|
+
* 3x Android screen than on iOS — and larger than the `onLayout` bounds the
|
|
181
|
+
* same screen reports, which is the comparison infinite-scroll code makes.
|
|
182
|
+
*/
|
|
183
|
+
private fun craftNativeScrollMetrics(
|
|
184
|
+
offsetX: Int,
|
|
185
|
+
offsetY: Int,
|
|
186
|
+
contentWidth: Int,
|
|
187
|
+
contentHeight: Int,
|
|
188
|
+
viewportWidth: Int,
|
|
189
|
+
viewportHeight: Int,
|
|
190
|
+
density: Double,
|
|
191
|
+
): JSONObject {
|
|
192
|
+
// A density of zero would be a broken DisplayMetrics; report pixels rather
|
|
193
|
+
// than NaN, which JSON cannot carry and a handler cannot compare.
|
|
194
|
+
val scale = if (density.isFinite() && density > 0.0) density else 1.0
|
|
195
|
+
return JSONObject()
|
|
196
|
+
.put("contentOffset", JSONObject().put("x", offsetX / scale).put("y", offsetY / scale))
|
|
197
|
+
.put("contentSize", JSONObject().put("width", contentWidth / scale).put("height", contentHeight / scale))
|
|
198
|
+
.put("layoutMeasurement", JSONObject().put("width", viewportWidth / scale).put("height", viewportHeight / scale))
|
|
199
|
+
}
|
|
200
|
+
|
|
152
201
|
private fun craftNativePickerValue(value: Any?): String? = when (value) {
|
|
153
202
|
is String -> value
|
|
154
203
|
is Number -> value.toDouble().takeIf { it.isFinite() }?.let { value.toString() }
|
|
@@ -391,6 +440,16 @@ private object CraftNativeSkewTransforms {
|
|
|
391
440
|
/** A DOM-free SVG surface for the geometry emitted by the shared STX native protocol. */
|
|
392
441
|
/** Local native chart selection: no JS events are needed while scrubbing. */
|
|
393
442
|
private class CraftNativeChartInspection(private val view: View) {
|
|
443
|
+
companion object {
|
|
444
|
+
/** The one chart with a reading pinned open, if any. */
|
|
445
|
+
private var pinned: java.lang.ref.WeakReference<CraftNativeChartInspection>? = null
|
|
446
|
+
/** A touch that starts off the pinned chart closes its reading; the touch still lands where it was aimed. */
|
|
447
|
+
fun touchBegan(event: MotionEvent) {
|
|
448
|
+
if (event.actionMasked != MotionEvent.ACTION_DOWN) return
|
|
449
|
+
val open = pinned?.get() ?: return
|
|
450
|
+
if (!open.contains(event.rawX, event.rawY)) open.dismiss()
|
|
451
|
+
}
|
|
452
|
+
}
|
|
394
453
|
private var fingerprint = ""
|
|
395
454
|
private var title = ""
|
|
396
455
|
private var points = emptyList<JSONObject>()
|
|
@@ -399,8 +458,9 @@ private class CraftNativeChartInspection(private val view: View) {
|
|
|
399
458
|
private var startY = 0f
|
|
400
459
|
private var dragging = false
|
|
401
460
|
private var abandoned = false
|
|
461
|
+
private var holding = false
|
|
402
462
|
private val slop = android.view.ViewConfiguration.get(view.context).scaledTouchSlop
|
|
403
|
-
private val hold = Runnable { if (!abandoned) selectAt(startX) }
|
|
463
|
+
private val hold = Runnable { if (!abandoned) { holding = true; selectAt(startX) } }
|
|
404
464
|
val enabled: Boolean get() = points.isNotEmpty()
|
|
405
465
|
private val density get() = view.resources.displayMetrics.density
|
|
406
466
|
private fun dp(value: Float) = value * density
|
|
@@ -414,7 +474,7 @@ private class CraftNativeChartInspection(private val view: View) {
|
|
|
414
474
|
val x = it.optDouble("x", Double.NaN)
|
|
415
475
|
x.isFinite() && x in 0.0..1.0 && it.opt("label") is String
|
|
416
476
|
}
|
|
417
|
-
|
|
477
|
+
dismiss()
|
|
418
478
|
view.isFocusable = enabled
|
|
419
479
|
view.invalidate()
|
|
420
480
|
}
|
|
@@ -431,16 +491,31 @@ private class CraftNativeChartInspection(private val view: View) {
|
|
|
431
491
|
view.sendAccessibilityEvent(android.view.accessibility.AccessibilityEvent.TYPE_VIEW_SELECTED)
|
|
432
492
|
view.invalidate()
|
|
433
493
|
}
|
|
434
|
-
private fun
|
|
435
|
-
if (view.width <= 0) return
|
|
494
|
+
private fun nearest(x: Float): Int? {
|
|
495
|
+
if (view.width <= 0) return null
|
|
436
496
|
val fraction = (x / view.width).coerceIn(0f, 1f)
|
|
437
|
-
points.indices.minByOrNull { kotlin.math.abs(points[it].optDouble("x") - fraction) }
|
|
497
|
+
return points.indices.minByOrNull { kotlin.math.abs(points[it].optDouble("x") - fraction) }
|
|
498
|
+
}
|
|
499
|
+
private fun selectAt(x: Float) { nearest(x)?.let { select(it); pinned = java.lang.ref.WeakReference(this) } }
|
|
500
|
+
/** Puts the reading away. Returns whether one was showing. */
|
|
501
|
+
fun dismiss(): Boolean {
|
|
502
|
+
if (pinned?.get() === this) pinned = null
|
|
503
|
+
if (selected < 0) return false
|
|
504
|
+
selected = -1
|
|
505
|
+
view.invalidate()
|
|
506
|
+
return true
|
|
507
|
+
}
|
|
508
|
+
/** A tap pins the reading under it; tapping the pinned reading again puts it away. */
|
|
509
|
+
private fun tapAt(x: Float) { if (nearest(x) == selected) dismiss() else selectAt(x) }
|
|
510
|
+
private fun contains(rawX: Float, rawY: Float): Boolean {
|
|
511
|
+
val origin = IntArray(2).also { view.getLocationOnScreen(it) }
|
|
512
|
+
return rawX >= origin[0] && rawX < origin[0] + view.width && rawY >= origin[1] && rawY < origin[1] + view.height
|
|
438
513
|
}
|
|
439
514
|
fun touch(event: MotionEvent): Boolean {
|
|
440
515
|
if (!enabled) return false
|
|
441
516
|
when (event.actionMasked) {
|
|
442
517
|
MotionEvent.ACTION_DOWN -> {
|
|
443
|
-
startX = event.x; startY = event.y; dragging = false; abandoned = false
|
|
518
|
+
startX = event.x; startY = event.y; dragging = false; abandoned = false; holding = false
|
|
444
519
|
view.postDelayed(hold, 250)
|
|
445
520
|
}
|
|
446
521
|
MotionEvent.ACTION_MOVE -> {
|
|
@@ -455,17 +530,19 @@ private class CraftNativeChartInspection(private val view: View) {
|
|
|
455
530
|
}
|
|
456
531
|
MotionEvent.ACTION_UP -> {
|
|
457
532
|
view.removeCallbacks(hold)
|
|
458
|
-
|
|
533
|
+
// A drag or a hold reads the chart only while the finger is down.
|
|
534
|
+
if (dragging || holding) dismiss()
|
|
535
|
+
else if (!abandoned) { view.performClick(); tapAt(event.x) }
|
|
459
536
|
view.parent?.requestDisallowInterceptTouchEvent(false)
|
|
460
537
|
}
|
|
461
538
|
MotionEvent.ACTION_CANCEL -> {
|
|
462
|
-
view.removeCallbacks(hold); abandoned = true;
|
|
463
|
-
view.parent?.requestDisallowInterceptTouchEvent(false)
|
|
539
|
+
view.removeCallbacks(hold); abandoned = true; dismiss()
|
|
540
|
+
view.parent?.requestDisallowInterceptTouchEvent(false)
|
|
464
541
|
}
|
|
465
542
|
}
|
|
466
543
|
return !abandoned
|
|
467
544
|
}
|
|
468
|
-
fun detach() { view.removeCallbacks(hold);
|
|
545
|
+
fun detach() { view.removeCallbacks(hold); dismiss() }
|
|
469
546
|
fun accessibility(info: android.view.accessibility.AccessibilityNodeInfo) {
|
|
470
547
|
if (!enabled) return
|
|
471
548
|
info.className = "android.widget.SeekBar"
|
|
@@ -480,7 +557,7 @@ private class CraftNativeChartInspection(private val view: View) {
|
|
|
480
557
|
when (action) {
|
|
481
558
|
android.view.accessibility.AccessibilityNodeInfo.ACTION_SCROLL_FORWARD -> select(if (selected < 0) 0 else selected + 1)
|
|
482
559
|
android.view.accessibility.AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD -> select(if (selected < 0) points.lastIndex else selected - 1)
|
|
483
|
-
android.view.accessibility.AccessibilityNodeInfo.ACTION_DISMISS ->
|
|
560
|
+
android.view.accessibility.AccessibilityNodeInfo.ACTION_DISMISS -> dismiss()
|
|
484
561
|
else -> return false
|
|
485
562
|
}
|
|
486
563
|
return true
|
|
@@ -1186,6 +1263,7 @@ internal object CraftNativePointers {
|
|
|
1186
1263
|
fun ownsTouch(view: View): Boolean = route(view).firstOrNull() !in setOf("none", "box-none")
|
|
1187
1264
|
|
|
1188
1265
|
fun dispatch(view: View, event: MotionEvent, native: (MotionEvent) -> Boolean): Boolean {
|
|
1266
|
+
CraftNativeChartInspection.touchBegan(event)
|
|
1189
1267
|
val state = states.getOrPut(view) { State() }
|
|
1190
1268
|
val route = route(view)
|
|
1191
1269
|
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
|
@@ -4827,6 +4905,17 @@ class MainActivity : AppCompatActivity() {
|
|
|
4827
4905
|
val chunks = CraftNativeChunks()
|
|
4828
4906
|
/** A `requestAnimationFrame` is waiting for the next frame. */
|
|
4829
4907
|
var frameRequested = false
|
|
4908
|
+
/**
|
|
4909
|
+
* Choreographer's frame time at this screen's first delivered frame.
|
|
4910
|
+
*
|
|
4911
|
+
* `requestAnimationFrame` hands its callback a `DOMHighResTimeStamp`:
|
|
4912
|
+
* milliseconds measured from the context's own origin, so the first one
|
|
4913
|
+
* is near zero. `frameTimeNanos` counts from boot, which on a device up
|
|
4914
|
+
* for a month is nine digits of offset, and UIKit already subtracts its
|
|
4915
|
+
* own origin (`frameTimeOrigin` in `CraftNativeScreen.swift`). Without
|
|
4916
|
+
* this the same shared animation code sees two different epochs.
|
|
4917
|
+
*/
|
|
4918
|
+
var frameTimeOrigin: Long? = null
|
|
4830
4919
|
/** The screen's timers (`setTimeout`), kept by the host. */
|
|
4831
4920
|
val timers = mutableMapOf<Int, Runnable>()
|
|
4832
4921
|
val drafts = mutableMapOf<String, String>()
|
|
@@ -5182,9 +5271,51 @@ class MainActivity : AppCompatActivity() {
|
|
|
5182
5271
|
}
|
|
5183
5272
|
val launch = initialDeepLink?.let { routes.appPath(it.toString(), urlSchemes) }
|
|
5184
5273
|
if (launch != null) deliveredInitialDeepLink = true
|
|
5185
|
-
|
|
5274
|
+
// A launch link is deliberately not gated. Suppressing one would have
|
|
5275
|
+
// to decide whether it is dropped or replayed after signing in, and
|
|
5276
|
+
// UIKit does not gate them either — its gate resolves the start path
|
|
5277
|
+
// while links are still queued for replay. Same gap, both hosts, and
|
|
5278
|
+
// better as its own change than as a silent asymmetry here.
|
|
5279
|
+
val start = if (launch != null) launch else gatedStartPath(routes.initialPath)
|
|
5280
|
+
selectTab(routes.tabFor(start)?.let { routes.tabs.indexOf(it) } ?: 0)
|
|
5186
5281
|
if (launch != null && routes.tabFor(launch) == null) navigateOpen(launch, fromLink = true)
|
|
5187
|
-
else if (routes.tabs.isEmpty() && screens.isEmpty()) navigateOpen(
|
|
5282
|
+
else if (routes.tabs.isEmpty() && screens.isEmpty()) navigateOpen(start)
|
|
5283
|
+
}
|
|
5284
|
+
|
|
5285
|
+
/**
|
|
5286
|
+
* Send a signed-out launch to the gate's redirect before any screen is
|
|
5287
|
+
* built (#499).
|
|
5288
|
+
*
|
|
5289
|
+
* The app script cannot do this. `openScreen` returns as soon as it has an
|
|
5290
|
+
* isolate and evaluates the script later on an executor, so opening the
|
|
5291
|
+
* app script before the first screen only creates its isolate first — the
|
|
5292
|
+
* two then evaluate with nothing ordering them, and whether the gate runs
|
|
5293
|
+
* before the first screen paints is a race. On a launch that follows an
|
|
5294
|
+
* expired session rather than a sign-out, losing that race paints the
|
|
5295
|
+
* previous session's kept snapshot.
|
|
5296
|
+
*
|
|
5297
|
+
* UIKit loses the same race by construction rather than by chance, which
|
|
5298
|
+
* is where this was reported; the fix is the same on both and so is the
|
|
5299
|
+
* config.
|
|
5300
|
+
*/
|
|
5301
|
+
private fun gatedStartPath(start: String): String {
|
|
5302
|
+
val gate = config.optJSONObject("nativeGate") ?: return start
|
|
5303
|
+
val redirect = gate.optString("redirect").takeIf { it.isNotBlank() } ?: return start
|
|
5304
|
+
val listed = gate.optJSONArray("publicPaths")
|
|
5305
|
+
val allowed = (0 until (listed?.length() ?: 0)).mapNotNull { index ->
|
|
5306
|
+
listed?.optString(index)?.takeIf { it.isNotBlank() }
|
|
5307
|
+
}
|
|
5308
|
+
if (CraftNativePrelude.gateAdmits(start, allowed)) return start
|
|
5309
|
+
val key = gate.optString("token").ifBlank { CraftNativePrelude.sessionTokenKey(config) }
|
|
5310
|
+
if (!stores.secureValue(key).isNullOrEmpty()) return start
|
|
5311
|
+
// A redirect nothing can draw would trade a private screen for a blank
|
|
5312
|
+
// one. Say so and leave the start path alone.
|
|
5313
|
+
if (routes.match(redirect) == null) {
|
|
5314
|
+
Log.w(TAG, "nativeGate redirect $redirect has no native screen; the gate did not run")
|
|
5315
|
+
return start
|
|
5316
|
+
}
|
|
5317
|
+
Log.i(TAG, "no sign-in under $key: starting on $redirect instead of $start")
|
|
5318
|
+
return redirect
|
|
5188
5319
|
}
|
|
5189
5320
|
|
|
5190
5321
|
private fun installChrome() {
|
|
@@ -5814,7 +5945,11 @@ class MainActivity : AppCompatActivity() {
|
|
|
5814
5945
|
if (!screen.active || !screen.frameRequested) return@postFrameCallback
|
|
5815
5946
|
if (visibleScreen !== screen && !screen.headless) return@postFrameCallback
|
|
5816
5947
|
screen.frameRequested = false
|
|
5817
|
-
|
|
5948
|
+
// Milliseconds from this screen's first frame, not from boot: see
|
|
5949
|
+
// Screen.frameTimeOrigin. Nanoseconds are subtracted before the
|
|
5950
|
+
// division so the difference keeps full resolution.
|
|
5951
|
+
val origin = screen.frameTimeOrigin ?: frameTime.also { screen.frameTimeOrigin = it }
|
|
5952
|
+
send(screen, "FRAME_FIRE", JSONObject().put("time", (frameTime - origin) / 1_000_000.0))
|
|
5818
5953
|
}
|
|
5819
5954
|
}
|
|
5820
5955
|
|
|
@@ -6575,6 +6710,8 @@ class MainActivity : AppCompatActivity() {
|
|
|
6575
6710
|
if (isSingleLine == multiline) isSingleLine = !multiline
|
|
6576
6711
|
val wantedLines = if (multiline) craftNativeInt(props.opt("numberOfLines"), Int.MAX_VALUE).coerceAtLeast(1) else 1
|
|
6577
6712
|
if (maxLines != wantedLines) maxLines = wantedLines
|
|
6713
|
+
val wantedGravity = craftNativeVerticalGravity(gravity, multiline)
|
|
6714
|
+
if (gravity != wantedGravity) gravity = wantedGravity
|
|
6578
6715
|
isEnabled = craftNativeBoolean(props.opt("editable"), true)
|
|
6579
6716
|
configureTextStyle(this, style)
|
|
6580
6717
|
// The style's padding is where the text starts, as on iOS and
|
|
@@ -7136,10 +7273,15 @@ class MainActivity : AppCompatActivity() {
|
|
|
7136
7273
|
onScroll = { scrollX, scrollY ->
|
|
7137
7274
|
screen.scrollHandlers[nativeScroll]?.let { current ->
|
|
7138
7275
|
val contentView = nativeScroll.content
|
|
7139
|
-
|
|
7140
|
-
|
|
7141
|
-
|
|
7142
|
-
|
|
7276
|
+
// Density-independent, like `emitLayoutEvent` and
|
|
7277
|
+
// like UIKit's points: see craftNativeScrollMetrics.
|
|
7278
|
+
val scale = resources.displayMetrics.density.toDouble()
|
|
7279
|
+
send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", craftNativeScrollMetrics(
|
|
7280
|
+
offsetX = scrollX, offsetY = scrollY,
|
|
7281
|
+
contentWidth = contentView.width, contentHeight = contentView.height,
|
|
7282
|
+
viewportWidth = nativeScroll.width, viewportHeight = nativeScroll.height,
|
|
7283
|
+
density = scale,
|
|
7284
|
+
)))
|
|
7143
7285
|
}
|
|
7144
7286
|
},
|
|
7145
7287
|
onBeginDrag = {
|
|
@@ -7476,16 +7618,12 @@ class MainActivity : AppCompatActivity() {
|
|
|
7476
7618
|
?: craftNativeOptionalString(props.opt("resizeMode")).orEmpty()
|
|
7477
7619
|
}
|
|
7478
7620
|
|
|
7479
|
-
private fun flatListScrollEvent(list: CraftNativeFlatList): JSONObject =
|
|
7480
|
-
.
|
|
7481
|
-
|
|
7482
|
-
|
|
7483
|
-
.
|
|
7484
|
-
|
|
7485
|
-
.put("height", list.computeVerticalScrollRange()))
|
|
7486
|
-
.put("layoutMeasurement", JSONObject()
|
|
7487
|
-
.put("width", list.width)
|
|
7488
|
-
.put("height", list.height))
|
|
7621
|
+
private fun flatListScrollEvent(list: CraftNativeFlatList): JSONObject = craftNativeScrollMetrics(
|
|
7622
|
+
offsetX = list.computeHorizontalScrollOffset(), offsetY = list.computeVerticalScrollOffset(),
|
|
7623
|
+
contentWidth = list.computeHorizontalScrollRange(), contentHeight = list.computeVerticalScrollRange(),
|
|
7624
|
+
viewportWidth = list.width, viewportHeight = list.height,
|
|
7625
|
+
density = resources.displayMetrics.density.toDouble(),
|
|
7626
|
+
)
|
|
7489
7627
|
|
|
7490
7628
|
private fun reconcileChildren(
|
|
7491
7629
|
screen: Screen,
|
package/dist/cli.js
CHANGED
|
@@ -1025,6 +1025,9 @@ async function detachDiskImage(mount, options = {}) {
|
|
|
1025
1025
|
await wait(attempt * 2000);
|
|
1026
1026
|
}
|
|
1027
1027
|
}
|
|
1028
|
+
function attachedDevice(attachOutput) {
|
|
1029
|
+
return attachOutput.match(/^(\/dev\/disk\d+)(?=\s)/m)?.[1] ?? null;
|
|
1030
|
+
}
|
|
1028
1031
|
async function createDiskImage(opts) {
|
|
1029
1032
|
if (!/^[^/:\n]{1,27}$/.test(opts.volumeName))
|
|
1030
1033
|
return { success: false, error: `Invalid DMG volume name "${opts.volumeName}"; must be 1..27 chars without /, :, or newline` };
|
|
@@ -1068,7 +1071,7 @@ async function createDiskImage(opts) {
|
|
|
1068
1071
|
phase = "layout";
|
|
1069
1072
|
writeInstallerLayout(mount, opts.volumeName, bundleName);
|
|
1070
1073
|
phase = "detach";
|
|
1071
|
-
const detached = await detachDiskImage(mount);
|
|
1074
|
+
const detached = await detachDiskImage(attachedDevice(attachment) ?? mount);
|
|
1072
1075
|
if (!detached.success) {
|
|
1073
1076
|
detachAttempts = detached.attempts;
|
|
1074
1077
|
throw new Error(detached.error);
|
|
@@ -1083,7 +1086,7 @@ async function createDiskImage(opts) {
|
|
|
1083
1086
|
Disk image lifecycle: ${JSON.stringify({ phase, image: writable, mount, attachment, detachAttempts })}` };
|
|
1084
1087
|
} finally {
|
|
1085
1088
|
if (mounted)
|
|
1086
|
-
await runTool("hdiutil", ["detach", mount, "-force"]);
|
|
1089
|
+
await runTool("hdiutil", ["detach", attachedDevice(attachment) ?? mount, "-force"]);
|
|
1087
1090
|
removeTempDir(scratch);
|
|
1088
1091
|
}
|
|
1089
1092
|
}
|
|
@@ -3243,7 +3246,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3243
3246
|
`);
|
|
3244
3247
|
}
|
|
3245
3248
|
// package.json
|
|
3246
|
-
var version = "0.0.
|
|
3249
|
+
var version = "0.0.491";
|
|
3247
3250
|
|
|
3248
3251
|
// bin/cli.ts
|
|
3249
3252
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
|
@@ -376,7 +376,7 @@ function craftBinaryNotFoundMessage(triedPath) {
|
|
|
376
376
|
`);
|
|
377
377
|
}
|
|
378
378
|
// package.json
|
|
379
|
-
var version = "0.0.
|
|
379
|
+
var version = "0.0.491";
|
|
380
380
|
|
|
381
381
|
// src/sdk-version.ts
|
|
382
382
|
var SDK_VERSION = version;
|
|
@@ -1393,6 +1393,9 @@ async function detachDiskImage(mount, options = {}) {
|
|
|
1393
1393
|
await wait(attempt * 2000);
|
|
1394
1394
|
}
|
|
1395
1395
|
}
|
|
1396
|
+
function attachedDevice(attachOutput) {
|
|
1397
|
+
return attachOutput.match(/^(\/dev\/disk\d+)(?=\s)/m)?.[1] ?? null;
|
|
1398
|
+
}
|
|
1396
1399
|
async function createDiskImage(opts) {
|
|
1397
1400
|
if (!/^[^/:\n]{1,27}$/.test(opts.volumeName))
|
|
1398
1401
|
return { success: false, error: `Invalid DMG volume name "${opts.volumeName}"; must be 1..27 chars without /, :, or newline` };
|
|
@@ -1436,7 +1439,7 @@ async function createDiskImage(opts) {
|
|
|
1436
1439
|
phase = "layout";
|
|
1437
1440
|
writeInstallerLayout(mount, opts.volumeName, bundleName);
|
|
1438
1441
|
phase = "detach";
|
|
1439
|
-
const detached = await detachDiskImage(mount);
|
|
1442
|
+
const detached = await detachDiskImage(attachedDevice(attachment) ?? mount);
|
|
1440
1443
|
if (!detached.success) {
|
|
1441
1444
|
detachAttempts = detached.attempts;
|
|
1442
1445
|
throw new Error(detached.error);
|
|
@@ -1451,7 +1454,7 @@ async function createDiskImage(opts) {
|
|
|
1451
1454
|
Disk image lifecycle: ${JSON.stringify({ phase, image: writable, mount, attachment, detachAttempts })}` };
|
|
1452
1455
|
} finally {
|
|
1453
1456
|
if (mounted)
|
|
1454
|
-
await runTool("hdiutil", ["detach", mount, "-force"]);
|
|
1457
|
+
await runTool("hdiutil", ["detach", attachedDevice(attachment) ?? mount, "-force"]);
|
|
1455
1458
|
removeTempDir(scratch);
|
|
1456
1459
|
}
|
|
1457
1460
|
}
|
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.
|
|
120
|
+
var version = "0.0.491";
|
|
121
121
|
|
|
122
122
|
// src/sdk-version.ts
|
|
123
123
|
var SDK_VERSION = version;
|
|
@@ -1149,6 +1149,9 @@ async function detachDiskImage(mount, options = {}) {
|
|
|
1149
1149
|
await wait(attempt * 2000);
|
|
1150
1150
|
}
|
|
1151
1151
|
}
|
|
1152
|
+
function attachedDevice(attachOutput) {
|
|
1153
|
+
return attachOutput.match(/^(\/dev\/disk\d+)(?=\s)/m)?.[1] ?? null;
|
|
1154
|
+
}
|
|
1152
1155
|
async function createDiskImage(opts) {
|
|
1153
1156
|
if (!/^[^/:\n]{1,27}$/.test(opts.volumeName))
|
|
1154
1157
|
return { success: false, error: `Invalid DMG volume name "${opts.volumeName}"; must be 1..27 chars without /, :, or newline` };
|
|
@@ -1192,7 +1195,7 @@ async function createDiskImage(opts) {
|
|
|
1192
1195
|
phase = "layout";
|
|
1193
1196
|
writeInstallerLayout(mount, opts.volumeName, bundleName);
|
|
1194
1197
|
phase = "detach";
|
|
1195
|
-
const detached = await detachDiskImage(mount);
|
|
1198
|
+
const detached = await detachDiskImage(attachedDevice(attachment) ?? mount);
|
|
1196
1199
|
if (!detached.success) {
|
|
1197
1200
|
detachAttempts = detached.attempts;
|
|
1198
1201
|
throw new Error(detached.error);
|
|
@@ -1207,7 +1210,7 @@ async function createDiskImage(opts) {
|
|
|
1207
1210
|
Disk image lifecycle: ${JSON.stringify({ phase, image: writable, mount, attachment, detachAttempts })}` };
|
|
1208
1211
|
} finally {
|
|
1209
1212
|
if (mounted)
|
|
1210
|
-
await runTool("hdiutil", ["detach", mount, "-force"]);
|
|
1213
|
+
await runTool("hdiutil", ["detach", attachedDevice(attachment) ?? mount, "-force"]);
|
|
1211
1214
|
removeTempDir(scratch);
|
|
1212
1215
|
}
|
|
1213
1216
|
}
|
package/dist/ios/src/index.d.ts
CHANGED
|
@@ -317,6 +317,28 @@ export interface CraftConfig {
|
|
|
317
317
|
* `craftsession` event on the page.
|
|
318
318
|
*/
|
|
319
319
|
sessionTokenKey?: string;
|
|
320
|
+
/**
|
|
321
|
+
* Which screens a signed-out launch may start on.
|
|
322
|
+
*
|
|
323
|
+
* The shell resolves this while it is deciding the start path, before any
|
|
324
|
+
* screen is built. An app script cannot do the same job: it is started from
|
|
325
|
+
* the first frame's completion block, so by the time it can read a token the
|
|
326
|
+
* signed-in screen it would have prevented has already been painted — with
|
|
327
|
+
* the previous session's kept snapshot in it when the launch follows an
|
|
328
|
+
* expired token rather than a sign-out.
|
|
329
|
+
*
|
|
330
|
+
* ```ts
|
|
331
|
+
* nativeGate: {
|
|
332
|
+
* publicPaths: ['/m/login', '/m/signup', '/m/store'],
|
|
333
|
+
* redirect: '/m/login',
|
|
334
|
+
* }
|
|
335
|
+
* ```
|
|
336
|
+
*
|
|
337
|
+
* `publicPaths` admits a listed path and anything under it, so `/m/store`
|
|
338
|
+
* covers `/m/store/42` and not `/m/storefront`. `token` defaults to
|
|
339
|
+
* `sessionTokenKey`, the item `craft.session` already follows.
|
|
340
|
+
*/
|
|
341
|
+
nativeGate?: NativeGate;
|
|
320
342
|
/**
|
|
321
343
|
* Crash and error reporting to bughq (CraftCrashReporter.swift): crashes
|
|
322
344
|
* and hangs (signal handlers, NSException, MetricKit), uncaught JavaScript
|
|
@@ -325,6 +347,25 @@ export interface CraftConfig {
|
|
|
325
347
|
*/
|
|
326
348
|
crashReporting?: CraftCrashReporting;
|
|
327
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* An auth gate the shell applies while resolving the start path.
|
|
352
|
+
*
|
|
353
|
+
* See `CraftConfig.nativeGate`. The allowlist is static data in the apps that
|
|
354
|
+
* need this, so emitting it into the config costs nothing at runtime.
|
|
355
|
+
*/
|
|
356
|
+
export interface NativeGate {
|
|
357
|
+
/**
|
|
358
|
+
* Paths a signed-out launch may start on, each admitting what sits under it.
|
|
359
|
+
*
|
|
360
|
+
* Not `public`: that is a Swift keyword, and remapping it would make the
|
|
361
|
+
* stored key differ from the property the host decodes it into.
|
|
362
|
+
*/
|
|
363
|
+
publicPaths?: string[];
|
|
364
|
+
/** Where a signed-out launch starts instead. Must itself be public. */
|
|
365
|
+
redirect: string;
|
|
366
|
+
/** The secure-storage key holding the sign-in. Defaults to `sessionTokenKey`. */
|
|
367
|
+
token?: string;
|
|
368
|
+
}
|
|
328
369
|
export interface CraftCrashReporting {
|
|
329
370
|
/** The bughq project's public ingest key. Ships in the app; it is not a secret. */
|
|
330
371
|
key: string;
|
|
@@ -362,7 +403,7 @@ export declare function usesNativeBundle(config: Pick<CraftConfig, 'renderer' |
|
|
|
362
403
|
*/
|
|
363
404
|
export declare function nativeStartPath(config: Pick<CraftConfig, 'devServerURL'>): string;
|
|
364
405
|
/** What is wrong with a config's hybrid and tab settings, one sentence each. */
|
|
365
|
-
export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer' | 'nativeScreens' | 'nativeScreenConstraints' | 'tabs' | 'shareStorage' | 'devServerURL' | 'nativeApp' | 'nativeScriptThread' | 'notificationCategories'>): string[];
|
|
406
|
+
export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer' | 'nativeScreens' | 'nativeScreenConstraints' | 'tabs' | 'shareStorage' | 'devServerURL' | 'nativeApp' | 'nativeScriptThread' | 'notificationCategories' | 'nativeGate'>): string[];
|
|
366
407
|
/**
|
|
367
408
|
* The builder installs a directory of route bundles (stx's `routesDir`) as
|
|
368
409
|
* well as the one-file bundle, and the app runs each screen from its own
|
package/dist/ios/src/index.js
CHANGED
|
@@ -740,6 +740,21 @@ function hybridConfigProblems(config) {
|
|
|
740
740
|
if (!matchNativePath(config.nativeScreens, start, config.nativeScreenConstraints))
|
|
741
741
|
problems.push(`The start path "${start}" (from devServerURL) is not a nativeScreens path; with renderer: "native" the app must start on a native screen.`);
|
|
742
742
|
}
|
|
743
|
+
const gate = config.nativeGate;
|
|
744
|
+
if (gate !== undefined) {
|
|
745
|
+
if (typeof gate.redirect !== "string" || normalizeNativePath(gate.redirect) !== gate.redirect)
|
|
746
|
+
problems.push(`nativeGate.redirect "${String(gate.redirect)}" must be an absolute path with no query, hash or trailing slash.`);
|
|
747
|
+
else if (hasNativeRoutes(config) && !matchNativePath(config.nativeScreens, gate.redirect, config.nativeScreenConstraints))
|
|
748
|
+
problems.push(`nativeGate.redirect "${gate.redirect}" is not a nativeScreens path; a signed-out launch would have nothing to show.`);
|
|
749
|
+
for (const path of gate.publicPaths ?? []) {
|
|
750
|
+
if (typeof path !== "string" || normalizeNativePath(path) !== path)
|
|
751
|
+
problems.push(`nativeGate.publicPaths "${String(path)}" must be an absolute path with no query, hash or trailing slash.`);
|
|
752
|
+
}
|
|
753
|
+
if (typeof gate.redirect === "string" && !(gate.publicPaths ?? []).some((path) => gate.redirect === path || gate.redirect.startsWith(`${path}/`)))
|
|
754
|
+
problems.push(`nativeGate.redirect "${gate.redirect}" must also be listed in nativeGate.publicPaths.`);
|
|
755
|
+
if (gate.token !== undefined && (typeof gate.token !== "string" || !gate.token))
|
|
756
|
+
problems.push("nativeGate.token must be the secure-storage key the sign-in lives under.");
|
|
757
|
+
}
|
|
743
758
|
if (config.nativeApp !== undefined) {
|
|
744
759
|
if (typeof config.nativeApp !== "string" || !/^[A-Za-z_$][\w$-]*$/.test(config.nativeApp))
|
|
745
760
|
problems.push(`nativeApp "${String(config.nativeApp)}" must be a route name from the native bundle.`);
|
|
@@ -2046,6 +2046,9 @@ struct CraftConfig: Codable {
|
|
|
2046
2046
|
/// (`auth.token` unless set): `craft.session` follows it, and a change of
|
|
2047
2047
|
/// it is `SESSION_CHANGED` on every screen (CraftSession).
|
|
2048
2048
|
var sessionTokenKey: String? = nil
|
|
2049
|
+
/// Which screens a signed-out launch may start on, resolved before the
|
|
2050
|
+
/// first frame is built (CraftHybrid.configure).
|
|
2051
|
+
var nativeGate: NativeGate? = nil
|
|
2049
2052
|
/// The bundle route of the app script: a screen-less script that runs for
|
|
2050
2053
|
/// the app's whole life and hears the app's events (CraftHybrid.swift).
|
|
2051
2054
|
var nativeApp: String? = nil
|
|
@@ -2057,6 +2060,33 @@ struct CraftConfig: Codable {
|
|
|
2057
2060
|
/// unless the config names a project key.
|
|
2058
2061
|
var crashReporting: CrashReporting? = nil
|
|
2059
2062
|
|
|
2063
|
+
/// An auth gate the shell applies while resolving the start path.
|
|
2064
|
+
///
|
|
2065
|
+
/// An app script cannot do this. It is started from the first frame's
|
|
2066
|
+
/// `CATransaction` completion block, so by the time it can read a token
|
|
2067
|
+
/// the signed-in screen it would have prevented is already on screen —
|
|
2068
|
+
/// with the previous session's kept snapshot in it when the launch
|
|
2069
|
+
/// follows an expired token rather than a sign-out (#499). The check has
|
|
2070
|
+
/// to belong to whoever decides the start path, and that is the shell.
|
|
2071
|
+
struct NativeGate: Codable {
|
|
2072
|
+
/// Paths that may be shown without a sign-in. A path is public when
|
|
2073
|
+
/// it is listed, or when it sits under a listed one: `/m/store`
|
|
2074
|
+
/// admits `/m/store/42` and not `/m/storefront`.
|
|
2075
|
+
///
|
|
2076
|
+
/// Spelled `publicPaths` rather than `public` in the stored config
|
|
2077
|
+
/// too. `public` is a Swift keyword, and remapping it through
|
|
2078
|
+
/// `CodingKeys` would make the property name and the stored key
|
|
2079
|
+
/// differ — which is exactly what the Zig conformance mirror checks,
|
|
2080
|
+
/// and a divergence worth not having for the sake of one word.
|
|
2081
|
+
var publicPaths: [String]? = nil
|
|
2082
|
+
/// Where a signed-out launch starts instead.
|
|
2083
|
+
var redirect: String? = nil
|
|
2084
|
+
/// The secure-storage key holding the sign-in. Defaults to
|
|
2085
|
+
/// `sessionTokenKey`, which is the same item `craft.session` follows,
|
|
2086
|
+
/// so an app that already declares one need not repeat it.
|
|
2087
|
+
var token: String? = nil
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2060
2090
|
struct Tab: Codable {
|
|
2061
2091
|
var id: String
|
|
2062
2092
|
var title: String
|
|
@@ -425,6 +425,7 @@ final class CraftHybrid: NSObject {
|
|
|
425
425
|
: "[craft hybrid] nativeScreens are set but dist/native-screen.js is missing; the app has no screens to show")
|
|
426
426
|
}
|
|
427
427
|
isEnabled = wanted && Self.hasBundle
|
|
428
|
+
applyGate(config)
|
|
428
429
|
if isEnabled, !hasWeb, routes.match(startPath) == nil {
|
|
429
430
|
// No web view to show it in: the first tab a screen draws.
|
|
430
431
|
let fallback = (config.tabs ?? []).map(\.id).first { routes.match($0) != nil }
|
|
@@ -458,6 +459,54 @@ final class CraftHybrid: NSObject {
|
|
|
458
459
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { self.startAppScript() }
|
|
459
460
|
}
|
|
460
461
|
|
|
462
|
+
/// Whether `path` may be shown to someone who is not signed in.
|
|
463
|
+
///
|
|
464
|
+
/// Listed, or under something listed: `/m/store` admits `/m/store/42` and
|
|
465
|
+
/// not `/m/storefront`. Prefixes are compared with the separator attached
|
|
466
|
+
/// so a listed path cannot admit a sibling that merely starts with its
|
|
467
|
+
/// letters.
|
|
468
|
+
static func gateAdmits(_ path: String, public allowed: [String]) -> Bool {
|
|
469
|
+
allowed.contains { entry in
|
|
470
|
+
var listed = entry
|
|
471
|
+
while listed.count > 1, listed.hasSuffix("/") { listed.removeLast() }
|
|
472
|
+
guard !listed.isEmpty else { return false }
|
|
473
|
+
if path == listed { return true }
|
|
474
|
+
// "/" is the root path, not a prefix of everything. Reading it as
|
|
475
|
+
// one would admit every path, which is a gate that is not a gate.
|
|
476
|
+
guard listed != "/" else { return false }
|
|
477
|
+
return path.hasPrefix(listed + "/")
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/// Send a signed-out launch to the gate's redirect before anything is built.
|
|
482
|
+
///
|
|
483
|
+
/// This runs while `startPath` is still being resolved, which is the
|
|
484
|
+
/// whole point: the app script that would otherwise do it does not start
|
|
485
|
+
/// until the first frame has been committed, so it can only push sign-in
|
|
486
|
+
/// on top of a screen that has already been drawn with the previous
|
|
487
|
+
/// session's data in it (#499).
|
|
488
|
+
///
|
|
489
|
+
/// The Keychain read is synchronous and can cost 100-900ms cold. It is
|
|
490
|
+
/// not an extra read: every signed-in screen reads this same item when it
|
|
491
|
+
/// opens, and `CraftKeychain` caches it, so the gate moves the app's first
|
|
492
|
+
/// read earlier rather than adding one. Only apps that declare a gate pay
|
|
493
|
+
/// it at all.
|
|
494
|
+
private func applyGate(_ config: CraftConfig) {
|
|
495
|
+
guard let gate = config.nativeGate, let redirect = gate.redirect, !redirect.isEmpty else { return }
|
|
496
|
+
guard !Self.gateAdmits(startPath, public: gate.publicPaths ?? []) else { return }
|
|
497
|
+
let key = gate.token ?? config.sessionTokenKey ?? "auth.token"
|
|
498
|
+
guard (CraftNativeActions.webSecureValue(forKey: key) ?? "").isEmpty else { return }
|
|
499
|
+
// A redirect nothing can draw would trade a private screen for a
|
|
500
|
+
// blank one. Say so and leave the start path alone; the fallback
|
|
501
|
+
// below then treats it as any other unroutable start.
|
|
502
|
+
if isEnabled, !hasWeb, routes.match(redirect) == nil {
|
|
503
|
+
NSLog("[craft hybrid] nativeGate redirect %@ has no native screen; the gate did not run", redirect)
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
NSLog("[craft hybrid] no sign-in under %@: starting on %@ instead of %@", key, redirect, startPath)
|
|
507
|
+
startPath = redirect
|
|
508
|
+
}
|
|
509
|
+
|
|
461
510
|
/// A silent push or a background refresh is waiting for the app script,
|
|
462
511
|
/// with iOS's clock running: it starts now rather than after a frame,
|
|
463
512
|
/// which a launch in the background never draws.
|
|
@@ -2001,7 +2001,15 @@ enum CraftSnapshots {
|
|
|
2001
2001
|
}
|
|
2002
2002
|
lock.unlock()
|
|
2003
2003
|
DispatchQueue.main.async {
|
|
2004
|
-
|
|
2004
|
+
// Posted whether or not the write landed. A screen displays its
|
|
2005
|
+
// checkpoint optimistically, and this notification carries only
|
|
2006
|
+
// the name, so a reader answers it by calling `read` — which
|
|
2007
|
+
// after a failure finds the previous value, because `pending`
|
|
2008
|
+
// was cleared above and the cache was only refilled on success.
|
|
2009
|
+
// Posting only on success left a failed checkpoint on screen as
|
|
2010
|
+
// though it had been kept; Android already restores it
|
|
2011
|
+
// (`changed("snapshots", name, if (ok) json else previous, null)`).
|
|
2012
|
+
NotificationCenter.default.post(name: changed, object: nil, userInfo: ["name": name])
|
|
2005
2013
|
completion?(ok)
|
|
2006
2014
|
}
|
|
2007
2015
|
}
|