craft-native 0.0.486 → 0.0.490

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.
@@ -183,6 +183,20 @@ export interface CraftAndroidConfig {
183
183
  * screen, and a change of it is `SESSION_CHANGED { key, token, previous }`.
184
184
  */
185
185
  sessionTokenKey?: string;
186
+ /**
187
+ * Which screens a signed-out launch may start on.
188
+ *
189
+ * The shell resolves this while choosing the start path, before any screen
190
+ * is built. The `nativeApp` script cannot do the same job: `openScreen`
191
+ * returns as soon as it has an isolate and evaluates the script later, so
192
+ * the gate and the first screen race — and on a launch that follows an
193
+ * expired token rather than a sign-out, losing paints the previous
194
+ * session's kept snapshot.
195
+ *
196
+ * The same shape as the iOS field, deliberately: one `nativeGate` block
197
+ * must gate both hosts or an app is protected on one and not the other.
198
+ */
199
+ nativeGate?: AndroidNativeGate;
186
200
  /** Wake the app script every so often (`craftBackgroundRefresh`), through WorkManager. */
187
201
  backgroundRefresh?: {
188
202
  enabled?: boolean;
@@ -208,6 +222,20 @@ export interface CraftAndroidConfig {
208
222
  sessions?: boolean;
209
223
  };
210
224
  }
225
+ /**
226
+ * An auth gate the shell applies while choosing the start path.
227
+ *
228
+ * See `CraftAndroidConfig.nativeGate`. Mirrors the iOS `NativeGate`, field for
229
+ * field, because one config block has to gate both hosts.
230
+ */
231
+ export interface AndroidNativeGate {
232
+ /** Paths a signed-out launch may start on, each admitting what sits under it. */
233
+ publicPaths?: string[];
234
+ /** Where a signed-out launch starts instead. */
235
+ redirect: string;
236
+ /** The secure-storage key holding the sign-in. Defaults to `sessionTokenKey`. */
237
+ token?: string;
238
+ }
211
239
  export interface CraftTabBarConfig {
212
240
  selection?: {
213
241
  /** The selected tab indicator's corner radius in dp; omitted keeps the default. */
@@ -1247,6 +1247,21 @@ function validateNativeRouteConfig(config) {
1247
1247
  if (!matchesNativeRoute(config.nativeScreens, start, config.nativeScreenConstraints))
1248
1248
  throw new Error(`The start path "${start}" (from devServerURL) is not a nativeScreens path; with renderer: "native" the app must start on a native screen.`);
1249
1249
  }
1250
+ const gate = config.nativeGate;
1251
+ if (gate !== undefined) {
1252
+ if (typeof gate.redirect !== "string" || normalizeNativeRoutePath(gate.redirect) !== gate.redirect)
1253
+ throw new Error(`nativeGate.redirect "${String(gate.redirect)}" must be an absolute path with no query, hash or trailing slash.`);
1254
+ if (config.renderer === "native" && screens.length > 0 && !matchesNativeRoute(config.nativeScreens, gate.redirect, config.nativeScreenConstraints))
1255
+ throw new Error(`nativeGate.redirect "${gate.redirect}" is not a nativeScreens path; a signed-out launch would have nothing to show.`);
1256
+ for (const path of gate.publicPaths ?? []) {
1257
+ if (typeof path !== "string" || normalizeNativeRoutePath(path) !== path)
1258
+ throw new Error(`nativeGate.publicPaths "${String(path)}" must be an absolute path with no query, hash or trailing slash.`);
1259
+ }
1260
+ if (!(gate.publicPaths ?? []).some((path) => gate.redirect === path || gate.redirect.startsWith(`${path}/`)))
1261
+ throw new Error(`nativeGate.redirect "${gate.redirect}" must also be listed in nativeGate.publicPaths.`);
1262
+ if (gate.token !== undefined && (typeof gate.token !== "string" || !gate.token))
1263
+ throw new Error("nativeGate.token must be the secure-storage key the sign-in lives under.");
1264
+ }
1250
1265
  if (config.nativeApp !== undefined) {
1251
1266
  if (typeof config.nativeApp !== "string" || !/^[A-Za-z_$][\w$-]*$/.test(config.nativeApp))
1252
1267
  throw new Error(`nativeApp "${String(config.nativeApp)}" must be a route name from the native bundle.`);
@@ -685,7 +685,16 @@ internal class CraftNativeServices(
685
685
  voiceRecorder = null
686
686
  val metadata = MediaMetadataRetriever()
687
687
  val duration = try { metadata.setDataSource(file.absolutePath); (metadata.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toDoubleOrNull() ?: 0.0) / 1000 } finally { metadata.release() }
688
- if (!file.exists() || file.length() == 0L || duration <= 0) throw IllegalStateException("Empty recording")
688
+ // Nothing recorded is its own answer, not a read failure.
689
+ // Thrown into the blanket catch below, it reached callers as
690
+ // RECORDING_FAILED and became indistinguishable from a damaged
691
+ // file, so an app could offer "nothing was recorded — try
692
+ // again" on iOS and not here (#496).
693
+ if (!file.exists() || file.length() == 0L || duration <= 0) {
694
+ cancelVoice()
695
+ done(failure("EMPTY_RECORDING", "The recording was empty"))
696
+ return
697
+ }
689
698
  done(success(JSONObject().put("path", file.absolutePath).put("name", file.name).put("type", "audio/mp4").put("size", file.length()).put("durationSeconds", duration)))
690
699
  } catch (_: Exception) { cancelVoice(); done(failure("RECORDING_FAILED", "The recording could not be read")) }
691
700
  return
@@ -398,6 +398,15 @@ internal class CraftNativeStores private constructor(
398
398
 
399
399
  fun snapshot(name: String): String? = snapshotCache[name]
400
400
 
401
+ /**
402
+ * One secure value, read directly rather than through the preload.
403
+ *
404
+ * For code that runs before any isolate exists. The auth gate resolves the
405
+ * start path before the first screen is built, so it cannot wait for the
406
+ * preload every isolate is handed (#499).
407
+ */
408
+ fun secureValue(key: String): String? = secure?.getString(key, null)
409
+
401
410
  /** `craft.storage.setSync`: null removes the key. Tells every isolate. */
402
411
  fun setStorage(key: String, json: String?, from: String?) {
403
412
  synchronized(CraftNativeStorage.lock) {
@@ -465,7 +474,19 @@ internal class CraftNativeStores private constructor(
465
474
  CraftNativeHub.broadcast("SYNC_UPDATE", payload, except = from)
466
475
  }
467
476
 
468
- private fun isSnapshotName(name: String): Boolean = name.matches(Regex("[A-Za-z0-9_-][A-Za-z0-9_.-]{0,127}"))
477
+ /**
478
+ * The same names UIKit accepts, so one checkpoint key is not valid on one
479
+ * host and refused on the other.
480
+ *
481
+ * `CraftSnapshots.isValidName` allows ASCII alphanumerics and `-_.:`, up
482
+ * to 128 characters, and rejects only a leading dot. The `:` is deliberate
483
+ * there — its own comment calls it the account namespace — so a key like
484
+ * `account:slot` wrote on iOS and came back `false` on Android.
485
+ *
486
+ * A colon never reaches the filesystem: `snapshotFile` percent-encodes the
487
+ * name, and `snapshotName` decodes it back.
488
+ */
489
+ private fun isSnapshotName(name: String): Boolean = name.matches(Regex("[A-Za-z0-9_:-][A-Za-z0-9_.:-]{0,127}"))
469
490
 
470
491
  private fun snapshotFile(name: String): File = File(snapshotsDir, URLEncoder.encode(name, "UTF-8") + ".json")
471
492
 
@@ -495,6 +516,32 @@ internal object CraftNativePrelude {
495
516
  */
496
517
  fun sessionTokenKey(config: JSONObject): String = config.optString("sessionTokenKey").ifBlank { "auth.token" }
497
518
 
519
+ /**
520
+ * Whether `path` may be shown to someone who is not signed in.
521
+ *
522
+ * Listed, or under something listed: `/m/store` admits `/m/store/42` and
523
+ * not `/m/storefront`. The separator is attached before comparing so a
524
+ * listed path cannot admit a sibling that merely starts with its letters.
525
+ *
526
+ * A literal mirror of `CraftHybrid.gateAdmits` in
527
+ * `packages/ios/templates/CraftHybrid.swift`, down to the trailing-slash
528
+ * trimming, because the two hosts must admit exactly the same paths from
529
+ * the same `publicPaths` list. `native-gate.test.ts` runs both against one
530
+ * table of cases.
531
+ */
532
+ fun gateAdmits(path: String, allowed: List<String>): Boolean = allowed.any { entry ->
533
+ var listed = entry
534
+ while (listed.length > 1 && listed.endsWith("/")) listed = listed.dropLast(1)
535
+ when {
536
+ listed.isEmpty() -> false
537
+ path == listed -> true
538
+ // "/" is the root path, not a prefix of everything. Reading it as
539
+ // one would admit every path, which is a gate that is not a gate.
540
+ listed == "/" -> false
541
+ else -> path.startsWith("$listed/")
542
+ }
543
+ }
544
+
498
545
  fun script(
499
546
  bridgeFields: String,
500
547
  post: String,
@@ -632,7 +679,12 @@ internal object CraftNativePrelude {
632
679
  if (message && message.type === 'FRAME_FIRE') {
633
680
  var due = frames;
634
681
  frames = [];
635
- var time = message.payload && message.payload.time || Date.now();
682
+ // A real first frame is 0, which `||` would replace with a Unix
683
+ // epoch value twelve orders of magnitude away. Only a missing or
684
+ // non-finite time falls back, and it falls back to the same
685
+ // origin-relative scale rather than to wall-clock.
686
+ var reported = message.payload && message.payload.time;
687
+ var time = typeof reported === 'number' && isFinite(reported) ? reported : 0;
636
688
  due.forEach(function(entry) {
637
689
  if (cancelledFrames[entry.id]) { delete cancelledFrames[entry.id]; return; }
638
690
  try { entry.fn(time); } catch (error) { console.error('[frame] ' + (error && error.stack || error)); }
@@ -704,8 +756,11 @@ internal object CraftNativePrelude {
704
756
  if (json === null || json === undefined) return null;
705
757
  try { return JSON.parse(json); } catch (error) { return null; }
706
758
  }
759
+ // The same names the host accepts, including the `:` UIKit calls the
760
+ // account namespace. This gate runs inside the isolate, so a stricter
761
+ // rule here refuses a key the host would have kept.
707
762
  function validSnapshotName(name) {
708
- return /^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,127}$/.test(name);
763
+ return /^[A-Za-z0-9_:-][A-Za-z0-9_.:-]{0,127}$/.test(name);
709
764
  }
710
765
  var craft = globalThis.craft = globalThis.craft || {};
711
766
  function keep(name, extras) {
@@ -735,7 +790,7 @@ internal object CraftNativePrelude {
735
790
  },
736
791
  set: function(name, value) {
737
792
  name = String(name);
738
- if (!validSnapshotName(name)) return Promise.reject(new TypeError('Snapshot names are letters, digits, ".", "_" and "-"'));
793
+ if (!validSnapshotName(name)) return Promise.reject(new TypeError('Snapshot names are letters, digits, ".", "_", "-" and ":"'));
739
794
  var json = value === null ? null : JSON.stringify(value);
740
795
  if (json === undefined) return Promise.reject(new TypeError('Snapshot ' + name + ' is not JSON'));
741
796
  if (json === null) delete sync.snapshots[name];
@@ -824,9 +879,24 @@ internal object CraftNativePrelude {
824
879
 
825
880
  /**
826
881
  * The bar above a screen (back, title, the screen's buttons) and the tab bar
827
- * below it, in plain Android views. A screen sets its bar with
828
- * `craft.navigation.setOptions` (title, largeTitle, hidden, rightButtons,
829
- * tabAccessory, tabBarHidden); the tabs come from the app's `tabs`.
882
+ * below it, in plain Android views. The tabs come from the app's `tabs`; a
883
+ * screen sets its own chrome with
884
+ *
885
+ * `craft.navigation.setOptions({ title, largeTitle, titleColor, hidden,
886
+ * backgroundColor, leftButtons, rightButtons, tabItem, tabAccessory,
887
+ * tabBarHidden, tabBarHiddenWhenPushed, tabSwipeEnabled })`
888
+ *
889
+ * which `apply` below reads. That list is checked against `apply` itself
890
+ * rather than kept by hand, and only the list is: the iOS comment named
891
+ * twelve keys while fifteen shipped (#489), and a first attempt at the same
892
+ * ratchet there passed with keys deleted because the prose underneath
893
+ * happened to mention them. Six of these twelve had never been written down.
894
+ *
895
+ * `tabBarHiddenWhenPushed` was honoured here and nowhere else in Craft until
896
+ * #493: hiding the tab bar on push is the ordinary iOS idiom, so a caller who
897
+ * set it got the behaviour on one host and silence on the other. UIKit reads
898
+ * the same key now, at the same depth, through CraftNativeScreen's
899
+ * `wantsTabBarHidden`.
830
900
  */
831
901
  internal class CraftNativeChrome(
832
902
  context: Context,
@@ -1316,7 +1386,25 @@ internal class CraftNativeChrome(
1316
1386
  if (badge > 0) {
1317
1387
  view.text = "${view.text}${if (button.optString("badgeStyle") == "dot") " •" else " $badge"}"
1318
1388
  }
1319
- view.setOnClickListener { onButton?.invoke(id) }
1389
+ // `menu`: the button opens a popup instead of reporting its own id, and
1390
+ // each choice is reported as a press of that choice's id.
1391
+ val choices = button.optJSONArray("menu")
1392
+ val menu = (0 until minOf(choices?.length() ?: 0, 12)).mapNotNull { index ->
1393
+ choices?.optJSONObject(index)?.takeIf { it.optString("id").isNotBlank() && it.optString("title").isNotBlank() }
1394
+ }
1395
+ view.setOnClickListener {
1396
+ if (menu.isEmpty()) onButton?.invoke(id)
1397
+ else android.widget.PopupMenu(context, view).apply {
1398
+ menu.forEachIndexed { index, choice ->
1399
+ this.menu.add(0, index, index, choice.optString("title")).isEnabled = !choice.optBoolean("disabled")
1400
+ }
1401
+ setOnMenuItemClickListener { selected ->
1402
+ menu.getOrNull(selected.itemId)?.let { onButton?.invoke(it.optString("id")) }
1403
+ true
1404
+ }
1405
+ show()
1406
+ }
1407
+ }
1320
1408
  view.layoutParams = LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT)
1321
1409
  return view
1322
1410
  }
@@ -149,9 +149,37 @@ 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
+ * A scroll event's geometry in density-independent units.
154
+ *
155
+ * UIKit reports `contentOffset`, `contentSize` and `layoutMeasurement` in
156
+ * points, and this host's own `emitLayoutEvent` already divides a View's
157
+ * pixels by the display density. These three were the exception: they shipped
158
+ * raw pixels, so the same scroll handler saw values three times larger on a
159
+ * 3x Android screen than on iOS — and larger than the `onLayout` bounds the
160
+ * same screen reports, which is the comparison infinite-scroll code makes.
161
+ */
162
+ private fun craftNativeScrollMetrics(
163
+ offsetX: Int,
164
+ offsetY: Int,
165
+ contentWidth: Int,
166
+ contentHeight: Int,
167
+ viewportWidth: Int,
168
+ viewportHeight: Int,
169
+ density: Double,
170
+ ): JSONObject {
171
+ // A density of zero would be a broken DisplayMetrics; report pixels rather
172
+ // than NaN, which JSON cannot carry and a handler cannot compare.
173
+ val scale = if (density.isFinite() && density > 0.0) density else 1.0
174
+ return JSONObject()
175
+ .put("contentOffset", JSONObject().put("x", offsetX / scale).put("y", offsetY / scale))
176
+ .put("contentSize", JSONObject().put("width", contentWidth / scale).put("height", contentHeight / scale))
177
+ .put("layoutMeasurement", JSONObject().put("width", viewportWidth / scale).put("height", viewportHeight / scale))
178
+ }
179
+
152
180
  private fun craftNativePickerValue(value: Any?): String? = when (value) {
153
- is String -> value.takeIf { it.isNotEmpty() }
154
- is Number -> value.toDouble().takeIf { it.isFinite() }?.toString()
181
+ is String -> value
182
+ is Number -> value.toDouble().takeIf { it.isFinite() }?.let { value.toString() }
155
183
  else -> null
156
184
  }
157
185
 
@@ -391,6 +419,16 @@ private object CraftNativeSkewTransforms {
391
419
  /** A DOM-free SVG surface for the geometry emitted by the shared STX native protocol. */
392
420
  /** Local native chart selection: no JS events are needed while scrubbing. */
393
421
  private class CraftNativeChartInspection(private val view: View) {
422
+ companion object {
423
+ /** The one chart with a reading pinned open, if any. */
424
+ private var pinned: java.lang.ref.WeakReference<CraftNativeChartInspection>? = null
425
+ /** A touch that starts off the pinned chart closes its reading; the touch still lands where it was aimed. */
426
+ fun touchBegan(event: MotionEvent) {
427
+ if (event.actionMasked != MotionEvent.ACTION_DOWN) return
428
+ val open = pinned?.get() ?: return
429
+ if (!open.contains(event.rawX, event.rawY)) open.dismiss()
430
+ }
431
+ }
394
432
  private var fingerprint = ""
395
433
  private var title = ""
396
434
  private var points = emptyList<JSONObject>()
@@ -399,8 +437,9 @@ private class CraftNativeChartInspection(private val view: View) {
399
437
  private var startY = 0f
400
438
  private var dragging = false
401
439
  private var abandoned = false
440
+ private var holding = false
402
441
  private val slop = android.view.ViewConfiguration.get(view.context).scaledTouchSlop
403
- private val hold = Runnable { if (!abandoned) selectAt(startX) }
442
+ private val hold = Runnable { if (!abandoned) { holding = true; selectAt(startX) } }
404
443
  val enabled: Boolean get() = points.isNotEmpty()
405
444
  private val density get() = view.resources.displayMetrics.density
406
445
  private fun dp(value: Float) = value * density
@@ -414,7 +453,7 @@ private class CraftNativeChartInspection(private val view: View) {
414
453
  val x = it.optDouble("x", Double.NaN)
415
454
  x.isFinite() && x in 0.0..1.0 && it.opt("label") is String
416
455
  }
417
- selected = -1
456
+ dismiss()
418
457
  view.isFocusable = enabled
419
458
  view.invalidate()
420
459
  }
@@ -431,16 +470,31 @@ private class CraftNativeChartInspection(private val view: View) {
431
470
  view.sendAccessibilityEvent(android.view.accessibility.AccessibilityEvent.TYPE_VIEW_SELECTED)
432
471
  view.invalidate()
433
472
  }
434
- private fun selectAt(x: Float) {
435
- if (view.width <= 0) return
473
+ private fun nearest(x: Float): Int? {
474
+ if (view.width <= 0) return null
436
475
  val fraction = (x / view.width).coerceIn(0f, 1f)
437
- points.indices.minByOrNull { kotlin.math.abs(points[it].optDouble("x") - fraction) }?.let { select(it) }
476
+ return points.indices.minByOrNull { kotlin.math.abs(points[it].optDouble("x") - fraction) }
477
+ }
478
+ private fun selectAt(x: Float) { nearest(x)?.let { select(it); pinned = java.lang.ref.WeakReference(this) } }
479
+ /** Puts the reading away. Returns whether one was showing. */
480
+ fun dismiss(): Boolean {
481
+ if (pinned?.get() === this) pinned = null
482
+ if (selected < 0) return false
483
+ selected = -1
484
+ view.invalidate()
485
+ return true
486
+ }
487
+ /** A tap pins the reading under it; tapping the pinned reading again puts it away. */
488
+ private fun tapAt(x: Float) { if (nearest(x) == selected) dismiss() else selectAt(x) }
489
+ private fun contains(rawX: Float, rawY: Float): Boolean {
490
+ val origin = IntArray(2).also { view.getLocationOnScreen(it) }
491
+ return rawX >= origin[0] && rawX < origin[0] + view.width && rawY >= origin[1] && rawY < origin[1] + view.height
438
492
  }
439
493
  fun touch(event: MotionEvent): Boolean {
440
494
  if (!enabled) return false
441
495
  when (event.actionMasked) {
442
496
  MotionEvent.ACTION_DOWN -> {
443
- startX = event.x; startY = event.y; dragging = false; abandoned = false
497
+ startX = event.x; startY = event.y; dragging = false; abandoned = false; holding = false
444
498
  view.postDelayed(hold, 250)
445
499
  }
446
500
  MotionEvent.ACTION_MOVE -> {
@@ -455,17 +509,19 @@ private class CraftNativeChartInspection(private val view: View) {
455
509
  }
456
510
  MotionEvent.ACTION_UP -> {
457
511
  view.removeCallbacks(hold)
458
- if (!abandoned) { view.performClick(); selectAt(event.x) }
512
+ // A drag or a hold reads the chart only while the finger is down.
513
+ if (dragging || holding) dismiss()
514
+ else if (!abandoned) { view.performClick(); tapAt(event.x) }
459
515
  view.parent?.requestDisallowInterceptTouchEvent(false)
460
516
  }
461
517
  MotionEvent.ACTION_CANCEL -> {
462
- view.removeCallbacks(hold); abandoned = true; selected = -1
463
- view.parent?.requestDisallowInterceptTouchEvent(false); view.invalidate()
518
+ view.removeCallbacks(hold); abandoned = true; dismiss()
519
+ view.parent?.requestDisallowInterceptTouchEvent(false)
464
520
  }
465
521
  }
466
522
  return !abandoned
467
523
  }
468
- fun detach() { view.removeCallbacks(hold); selected = -1 }
524
+ fun detach() { view.removeCallbacks(hold); dismiss() }
469
525
  fun accessibility(info: android.view.accessibility.AccessibilityNodeInfo) {
470
526
  if (!enabled) return
471
527
  info.className = "android.widget.SeekBar"
@@ -480,7 +536,7 @@ private class CraftNativeChartInspection(private val view: View) {
480
536
  when (action) {
481
537
  android.view.accessibility.AccessibilityNodeInfo.ACTION_SCROLL_FORWARD -> select(if (selected < 0) 0 else selected + 1)
482
538
  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() }
539
+ android.view.accessibility.AccessibilityNodeInfo.ACTION_DISMISS -> dismiss()
484
540
  else -> return false
485
541
  }
486
542
  return true
@@ -1186,6 +1242,7 @@ internal object CraftNativePointers {
1186
1242
  fun ownsTouch(view: View): Boolean = route(view).firstOrNull() !in setOf("none", "box-none")
1187
1243
 
1188
1244
  fun dispatch(view: View, event: MotionEvent, native: (MotionEvent) -> Boolean): Boolean {
1245
+ CraftNativeChartInspection.touchBegan(event)
1189
1246
  val state = states.getOrPut(view) { State() }
1190
1247
  val route = route(view)
1191
1248
  if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
@@ -4827,6 +4884,17 @@ class MainActivity : AppCompatActivity() {
4827
4884
  val chunks = CraftNativeChunks()
4828
4885
  /** A `requestAnimationFrame` is waiting for the next frame. */
4829
4886
  var frameRequested = false
4887
+ /**
4888
+ * Choreographer's frame time at this screen's first delivered frame.
4889
+ *
4890
+ * `requestAnimationFrame` hands its callback a `DOMHighResTimeStamp`:
4891
+ * milliseconds measured from the context's own origin, so the first one
4892
+ * is near zero. `frameTimeNanos` counts from boot, which on a device up
4893
+ * for a month is nine digits of offset, and UIKit already subtracts its
4894
+ * own origin (`frameTimeOrigin` in `CraftNativeScreen.swift`). Without
4895
+ * this the same shared animation code sees two different epochs.
4896
+ */
4897
+ var frameTimeOrigin: Long? = null
4830
4898
  /** The screen's timers (`setTimeout`), kept by the host. */
4831
4899
  val timers = mutableMapOf<Int, Runnable>()
4832
4900
  val drafts = mutableMapOf<String, String>()
@@ -5182,9 +5250,51 @@ class MainActivity : AppCompatActivity() {
5182
5250
  }
5183
5251
  val launch = initialDeepLink?.let { routes.appPath(it.toString(), urlSchemes) }
5184
5252
  if (launch != null) deliveredInitialDeepLink = true
5185
- selectTab(routes.tabFor(launch ?: routes.initialPath)?.let { routes.tabs.indexOf(it) } ?: 0)
5253
+ // A launch link is deliberately not gated. Suppressing one would have
5254
+ // to decide whether it is dropped or replayed after signing in, and
5255
+ // UIKit does not gate them either — its gate resolves the start path
5256
+ // while links are still queued for replay. Same gap, both hosts, and
5257
+ // better as its own change than as a silent asymmetry here.
5258
+ val start = if (launch != null) launch else gatedStartPath(routes.initialPath)
5259
+ selectTab(routes.tabFor(start)?.let { routes.tabs.indexOf(it) } ?: 0)
5186
5260
  if (launch != null && routes.tabFor(launch) == null) navigateOpen(launch, fromLink = true)
5187
- else if (routes.tabs.isEmpty() && screens.isEmpty()) navigateOpen(routes.initialPath)
5261
+ else if (routes.tabs.isEmpty() && screens.isEmpty()) navigateOpen(start)
5262
+ }
5263
+
5264
+ /**
5265
+ * Send a signed-out launch to the gate's redirect before any screen is
5266
+ * built (#499).
5267
+ *
5268
+ * The app script cannot do this. `openScreen` returns as soon as it has an
5269
+ * isolate and evaluates the script later on an executor, so opening the
5270
+ * app script before the first screen only creates its isolate first — the
5271
+ * two then evaluate with nothing ordering them, and whether the gate runs
5272
+ * before the first screen paints is a race. On a launch that follows an
5273
+ * expired session rather than a sign-out, losing that race paints the
5274
+ * previous session's kept snapshot.
5275
+ *
5276
+ * UIKit loses the same race by construction rather than by chance, which
5277
+ * is where this was reported; the fix is the same on both and so is the
5278
+ * config.
5279
+ */
5280
+ private fun gatedStartPath(start: String): String {
5281
+ val gate = config.optJSONObject("nativeGate") ?: return start
5282
+ val redirect = gate.optString("redirect").takeIf { it.isNotBlank() } ?: return start
5283
+ val listed = gate.optJSONArray("publicPaths")
5284
+ val allowed = (0 until (listed?.length() ?: 0)).mapNotNull { index ->
5285
+ listed?.optString(index)?.takeIf { it.isNotBlank() }
5286
+ }
5287
+ if (CraftNativePrelude.gateAdmits(start, allowed)) return start
5288
+ val key = gate.optString("token").ifBlank { CraftNativePrelude.sessionTokenKey(config) }
5289
+ if (!stores.secureValue(key).isNullOrEmpty()) return start
5290
+ // A redirect nothing can draw would trade a private screen for a blank
5291
+ // one. Say so and leave the start path alone.
5292
+ if (routes.match(redirect) == null) {
5293
+ Log.w(TAG, "nativeGate redirect $redirect has no native screen; the gate did not run")
5294
+ return start
5295
+ }
5296
+ Log.i(TAG, "no sign-in under $key: starting on $redirect instead of $start")
5297
+ return redirect
5188
5298
  }
5189
5299
 
5190
5300
  private fun installChrome() {
@@ -5814,7 +5924,11 @@ class MainActivity : AppCompatActivity() {
5814
5924
  if (!screen.active || !screen.frameRequested) return@postFrameCallback
5815
5925
  if (visibleScreen !== screen && !screen.headless) return@postFrameCallback
5816
5926
  screen.frameRequested = false
5817
- send(screen, "FRAME_FIRE", JSONObject().put("time", frameTime / 1_000_000.0))
5927
+ // Milliseconds from this screen's first frame, not from boot: see
5928
+ // Screen.frameTimeOrigin. Nanoseconds are subtracted before the
5929
+ // division so the difference keeps full resolution.
5930
+ val origin = screen.frameTimeOrigin ?: frameTime.also { screen.frameTimeOrigin = it }
5931
+ send(screen, "FRAME_FIRE", JSONObject().put("time", (frameTime - origin) / 1_000_000.0))
5818
5932
  }
5819
5933
  }
5820
5934
 
@@ -7136,10 +7250,15 @@ class MainActivity : AppCompatActivity() {
7136
7250
  onScroll = { scrollX, scrollY ->
7137
7251
  screen.scrollHandlers[nativeScroll]?.let { current ->
7138
7252
  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))))
7253
+ // Density-independent, like `emitLayoutEvent` and
7254
+ // like UIKit's points: see craftNativeScrollMetrics.
7255
+ val scale = resources.displayMetrics.density.toDouble()
7256
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", craftNativeScrollMetrics(
7257
+ offsetX = scrollX, offsetY = scrollY,
7258
+ contentWidth = contentView.width, contentHeight = contentView.height,
7259
+ viewportWidth = nativeScroll.width, viewportHeight = nativeScroll.height,
7260
+ density = scale,
7261
+ )))
7143
7262
  }
7144
7263
  },
7145
7264
  onBeginDrag = {
@@ -7476,16 +7595,12 @@ class MainActivity : AppCompatActivity() {
7476
7595
  ?: craftNativeOptionalString(props.opt("resizeMode")).orEmpty()
7477
7596
  }
7478
7597
 
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))
7598
+ private fun flatListScrollEvent(list: CraftNativeFlatList): JSONObject = craftNativeScrollMetrics(
7599
+ offsetX = list.computeHorizontalScrollOffset(), offsetY = list.computeVerticalScrollOffset(),
7600
+ contentWidth = list.computeHorizontalScrollRange(), contentHeight = list.computeVerticalScrollRange(),
7601
+ viewportWidth = list.width, viewportHeight = list.height,
7602
+ density = resources.displayMetrics.density.toDouble(),
7603
+ )
7489
7604
 
7490
7605
  private fun reconcileChildren(
7491
7606
  screen: Screen,
package/dist/cli.js CHANGED
@@ -3243,7 +3243,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3243
3243
  `);
3244
3244
  }
3245
3245
  // package.json
3246
- var version = "0.0.486";
3246
+ var version = "0.0.490";
3247
3247
 
3248
3248
  // bin/cli.ts
3249
3249
  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.486";
379
+ var version = "0.0.490";
380
380
 
381
381
  // src/sdk-version.ts
382
382
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.486";
120
+ var version = "0.0.490";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -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.`);