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.
@@ -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
- selected = -1
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 selectAt(x: Float) {
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) }?.let { select(it) }
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
- if (!abandoned) { view.performClick(); selectAt(event.x) }
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; selected = -1
463
- view.parent?.requestDisallowInterceptTouchEvent(false); view.invalidate()
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); selected = -1 }
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 -> { selected = -1; view.invalidate() }
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
- selectTab(routes.tabFor(launch ?: routes.initialPath)?.let { routes.tabs.indexOf(it) } ?: 0)
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(routes.initialPath)
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
- send(screen, "FRAME_FIRE", JSONObject().put("time", frameTime / 1_000_000.0))
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
- send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()
7140
- .put("contentOffset", JSONObject().put("x", scrollX).put("y", scrollY))
7141
- .put("contentSize", JSONObject().put("width", contentView.width).put("height", contentView.height))
7142
- .put("layoutMeasurement", JSONObject().put("width", nativeScroll.width).put("height", nativeScroll.height))))
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 = JSONObject()
7480
- .put("contentOffset", JSONObject()
7481
- .put("x", list.computeHorizontalScrollOffset())
7482
- .put("y", list.computeVerticalScrollOffset()))
7483
- .put("contentSize", JSONObject()
7484
- .put("width", list.computeHorizontalScrollRange())
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.487";
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.487";
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.487";
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
  }
@@ -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
@@ -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
- if ok { NotificationCenter.default.post(name: changed, object: nil, userInfo: ["name": name]) }
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
  }